From 6649e4025ee82d6380c7f1152e468f0c0229a872 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 18 May 2026 10:01:56 +0800 Subject: [PATCH] feat(dify-ui): add Checkbox/CheckboxGroup primitives (#36271) --- packages/dify-ui/package.json | 8 + .../checkbox-group/__tests__/index.spec.tsx | 76 +++++++++ .../src/checkbox-group/index.stories.tsx | 116 ++++++++++++++ packages/dify-ui/src/checkbox-group/index.tsx | 10 ++ .../src/checkbox/__tests__/index.spec.tsx | 129 ++++++++++++++++ .../dify-ui/src/checkbox/index.stories.tsx | 145 ++++++++++++++++++ packages/dify-ui/src/checkbox/index.tsx | 100 ++++++++++++ .../checkbox-list/__tests__/index.spec.tsx | 44 +++--- .../components/base/checkbox-list/index.tsx | 134 ++++++---------- .../base/__tests__/base-field.spec.tsx | 4 +- .../base/form/components/base/base-field.tsx | 2 +- .../__tests__/modal.spec.tsx | 7 +- .../metadata/edit-metadata-batch/modal.tsx | 18 ++- .../_base/components/form-input-item.tsx | 2 +- 14 files changed, 670 insertions(+), 125 deletions(-) create mode 100644 packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/checkbox-group/index.stories.tsx create mode 100644 packages/dify-ui/src/checkbox-group/index.tsx create mode 100644 packages/dify-ui/src/checkbox/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/checkbox/index.stories.tsx create mode 100644 packages/dify-ui/src/checkbox/index.tsx diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 96c512f89c..ee20896570 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -25,6 +25,14 @@ "types": "./src/button/index.tsx", "import": "./src/button/index.tsx" }, + "./checkbox": { + "types": "./src/checkbox/index.tsx", + "import": "./src/checkbox/index.tsx" + }, + "./checkbox-group": { + "types": "./src/checkbox-group/index.tsx", + "import": "./src/checkbox-group/index.tsx" + }, "./combobox": { "types": "./src/combobox/index.tsx", "import": "./src/combobox/index.tsx" diff --git a/packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx b/packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx new file mode 100644 index 0000000000..b5d8e65c99 --- /dev/null +++ b/packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx @@ -0,0 +1,76 @@ +import { Field } from '@base-ui/react/field' +import { Fieldset } from '@base-ui/react/fieldset' +import { useState } from 'react' +import { render } from 'vitest-browser-react' +import { Checkbox } from '../../checkbox' +import { CheckboxGroup } from '../index' + +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + +describe('CheckboxGroup', () => { + it('should manage selected values and parent mixed state', async () => { + function PermissionsDemo() { + const [value, setValue] = useState(['read']) + + return ( + + + + + Read + + + + Write + + + ) + } + + const screen = await render() + const parent = screen.getByRole('checkbox', { name: 'All permissions' }) + const write = screen.getByRole('checkbox', { name: 'Write' }) + + await expect.element(parent).toHaveAttribute('aria-checked', 'mixed') + await expect.element(parent).toHaveAttribute('data-indeterminate', '') + await expect.element(write).toHaveAttribute('aria-checked', 'false') + + asHTMLElement(parent.element()).click() + + await vi.waitFor(async () => { + await expect.element(parent).toHaveAttribute('aria-checked', 'true') + await expect.element(write).toHaveAttribute('aria-checked', 'true') + }) + }) + + it('should compose with Base UI Field and Fieldset without losing labels', async () => { + const onValueChange = vi.fn() + const screen = await render( + + }> + Features + + + + Search + + + + + + Analytics + + + + , + ) + + const analytics = screen.getByRole('checkbox', { name: 'Analytics' }) + await expect.element(analytics).toHaveAttribute('aria-checked', 'false') + + asHTMLElement(analytics.element()).click() + + expect(onValueChange).toHaveBeenCalledTimes(1) + expect(onValueChange.mock.calls[0]?.[0]).toEqual(['search', 'analytics']) + }) +}) diff --git a/packages/dify-ui/src/checkbox-group/index.stories.tsx b/packages/dify-ui/src/checkbox-group/index.stories.tsx new file mode 100644 index 0000000000..623ae62c98 --- /dev/null +++ b/packages/dify-ui/src/checkbox-group/index.stories.tsx @@ -0,0 +1,116 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Field } from '@base-ui/react/field' +import { Fieldset } from '@base-ui/react/fieldset' +import { useId, useState } from 'react' +import { CheckboxGroup } from '.' +import { Checkbox } from '../checkbox' + +const meta = { + title: 'Base/UI/CheckboxGroup', + component: CheckboxGroup, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'CheckboxGroup primitive built on Base UI. It owns multi-checkbox array state, allValues, and parent checkbox semantics. Import from `@langgenius/dify-ui/checkbox-group` and compose with `Checkbox` from `@langgenius/dify-ui/checkbox`.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +function DocumentSelectionDemo() { + const documentIds = ['doc-1', 'doc-2', 'doc-3'] + const [selected, setSelected] = useState(['doc-1']) + const groupLabelId = useId() + + return ( + + + + Current page documents + + + {[ + { id: 'doc-1', name: 'onboarding-guide.pdf' }, + { id: 'doc-2', name: 'pricing-faq.md' }, + { id: 'doc-3', name: 'release-notes.txt' }, + ].map(document => ( + + + {document.name} + + ))} + + + ) +} + +export const DocumentSelection: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Matches Dify table/list selection patterns such as documents, segments, annotations, and install bundle items: CheckboxGroup owns the selected ID array, allValues defines the current selectable page, and the parent checkbox provides select-all plus mixed state.', + }, + }, + }, +} + +function DynamicFormFieldDemo() { + const options = [ + { value: 'markdown', label: 'Markdown' }, + { value: 'pdf', label: 'PDF' }, + { value: 'html', label: 'HTML' }, + ] + const [selected, setSelected] = useState(['markdown']) + + return ( + + + This mirrors Dify dynamic form fields where checkbox options are controlled by schema and persisted as a string array. + + + )} + > + + Allowed file types + + {options.map(option => ( + + + + {option.label} + + + ))} + + + ) +} + +export const DynamicFormField: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Matches Dify checkbox-list form usage in workflow node forms and base form rendering. Field and Fieldset provide group labeling; CheckboxGroup owns controlled array state.', + }, + }, + }, +} diff --git a/packages/dify-ui/src/checkbox-group/index.tsx b/packages/dify-ui/src/checkbox-group/index.tsx new file mode 100644 index 0000000000..c3d8fab6a1 --- /dev/null +++ b/packages/dify-ui/src/checkbox-group/index.tsx @@ -0,0 +1,10 @@ +'use client' + +import type { CheckboxGroup as BaseCheckboxGroupNS } from '@base-ui/react/checkbox-group' +import { CheckboxGroup as BaseCheckboxGroup } from '@base-ui/react/checkbox-group' + +export type CheckboxGroupProps = BaseCheckboxGroupNS.Props + +export function CheckboxGroup(props: CheckboxGroupProps) { + return +} diff --git a/packages/dify-ui/src/checkbox/__tests__/index.spec.tsx b/packages/dify-ui/src/checkbox/__tests__/index.spec.tsx new file mode 100644 index 0000000000..77edc59892 --- /dev/null +++ b/packages/dify-ui/src/checkbox/__tests__/index.spec.tsx @@ -0,0 +1,129 @@ +import { render } from 'vitest-browser-react' +import { + Checkbox, + CheckboxIndicator, + CheckboxRoot, + CheckboxSkeleton, +} from '../index' + +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + +describe('Checkbox', () => { + it('should render an unchecked checkbox with Base UI semantics', async () => { + const screen = await render() + const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' }) + + await expect.element(checkbox).toHaveAttribute('aria-checked', 'false') + await expect.element(checkbox).toHaveAttribute('data-unchecked', '') + await expect.element(checkbox).not.toHaveAttribute('data-checked') + await expect.element(checkbox).not.toHaveAttribute('data-indeterminate') + }) + + it('should expose checked data attributes and icon styling hooks', async () => { + const screen = await render() + const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' }) + + await expect.element(checkbox).toHaveAttribute('aria-checked', 'true') + await expect.element(checkbox).toHaveAttribute('data-checked', '') + await expect.element(checkbox).toHaveClass('data-checked:bg-components-checkbox-bg') + expect(screen.container.querySelector('.i-ri-check-line')).toBeInTheDocument() + }) + + it('should expose mixed state when indeterminate', async () => { + const screen = await render() + const checkbox = screen.getByRole('checkbox', { name: 'Select all' }) + + await expect.element(checkbox).toHaveAttribute('aria-checked', 'mixed') + await expect.element(checkbox).toHaveAttribute('data-indeterminate', '') + expect(screen.container.querySelector('.i-ri-check-line')).not.toBeInTheDocument() + expect(screen.container.querySelector('span span.rounded-full.bg-current')).toBeInTheDocument() + }) + + it('should call onCheckedChange with the next checked value', async () => { + const onCheckedChange = vi.fn() + const screen = await render( + , + ) + + asHTMLElement(screen.getByRole('checkbox', { name: 'Accept terms' }).element()).click() + + expect(onCheckedChange).toHaveBeenCalledTimes(1) + expect(onCheckedChange.mock.calls[0]?.[0]).toBe(true) + }) + + it('should stay controlled until the checked prop changes', async () => { + const onCheckedChange = vi.fn() + const screen = await render( + , + ) + const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' }) + + asHTMLElement(checkbox.element()).click() + expect(onCheckedChange.mock.calls[0]?.[0]).toBe(true) + await expect.element(checkbox).toHaveAttribute('aria-checked', 'false') + + await screen.rerender() + await expect.element(screen.getByRole('checkbox', { name: 'Accept terms' })).toHaveAttribute('aria-checked', 'true') + }) + + it('should ignore interaction when disabled', async () => { + const onCheckedChange = vi.fn() + const screen = await render( + , + ) + const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' }) + + await expect.element(checkbox).toHaveAttribute('data-disabled', '') + await expect.element(checkbox).toHaveClass('data-disabled:cursor-not-allowed') + + asHTMLElement(checkbox.element()).click() + + expect(onCheckedChange).not.toHaveBeenCalled() + }) + + it('should submit checked and unchecked form values through the hidden input', async () => { + const screen = await render( + + + + , + ) + const form = screen.container.querySelector('form') as HTMLFormElement + const data = new FormData(form) + + expect(data.get('terms')).toBe('accepted') + expect(data.get('newsletter')).toBe('no') + }) + + it('should support custom compound composition with CheckboxRoot and CheckboxIndicator', async () => { + const screen = await render( + + + , + ) + + await expect.element(screen.getByRole('checkbox', { name: 'Custom checkbox' })).toHaveClass('custom-root') + expect(screen.container.querySelector('.custom-indicator')).toBeInTheDocument() + }) +}) + +describe('CheckboxSkeleton', () => { + it('should render a visual placeholder without checkbox semantics', async () => { + const screen = await render() + + expect(screen.container.querySelector('[role="checkbox"]')).not.toBeInTheDocument() + await expect.element(screen.getByTestId('checkbox-skeleton')).toHaveClass('bg-text-quaternary', 'opacity-20') + }) +}) diff --git a/packages/dify-ui/src/checkbox/index.stories.tsx b/packages/dify-ui/src/checkbox/index.stories.tsx new file mode 100644 index 0000000000..97cd1a1d60 --- /dev/null +++ b/packages/dify-ui/src/checkbox/index.stories.tsx @@ -0,0 +1,145 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { ComponentProps } from 'react' +import { useState } from 'react' +import { + Checkbox, + CheckboxSkeleton, +} from '.' + +const meta = { + title: 'Base/UI/Checkbox', + component: Checkbox, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Checkbox primitive built on Base UI. It preserves Base UI checked, indeterminate, disabled, and hidden input semantics while applying the Dify 16px checkbox design from Figma. Import from `@langgenius/dify-ui/checkbox`.', + }, + }, + }, + tags: ['autodocs'], + args: { + checked: false, + disabled: false, + indeterminate: false, + }, + argTypes: { + checked: { + control: 'boolean', + description: 'Controlled checked state.', + }, + indeterminate: { + control: 'boolean', + description: 'Mixed state used by parent or select-all checkboxes.', + }, + disabled: { + control: 'boolean', + description: 'Disables user interaction and exposes Base UI disabled state attributes.', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +function CheckboxDemo(args: Partial>) { + const [checked, setChecked] = useState(args.checked ?? false) + + return ( + + + Enable feature + + ) +} + +export const Default: Story = { + render: args => , + args: { + checked: false, + indeterminate: false, + disabled: false, + }, +} + +export const Checked: Story = { + render: args => , + args: { + checked: true, + indeterminate: false, + disabled: false, + }, +} + +export const Indeterminate: Story = { + args: { + 'checked': false, + 'indeterminate': true, + 'disabled': false, + 'aria-label': 'Partial selection', + }, +} + +export const Disabled: Story = { + render: () => ( + + + + Disabled unchecked + + + + Disabled checked + + + + Disabled mixed + + + ), +} + +function StateMatrixDemo() { + const states = [ + { label: 'Unchecked', checked: false }, + { label: 'Checked', checked: true }, + { label: 'Indeterminate', checked: false, indeterminate: true }, + { label: 'Disabled unchecked', checked: false, disabled: true }, + { label: 'Disabled checked', checked: true, disabled: true }, + { label: 'Disabled indeterminate', checked: false, indeterminate: true, disabled: true }, + ] + + return ( + + {states.map(state => ( + + + {state.label} + + ))} + + + Skeleton + + + ) +} + +export const StateMatrix: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'The full visual matrix for Dify checkbox states. State styling comes from Base UI data attributes such as data-checked, data-indeterminate, and data-disabled.', + }, + }, + }, +} diff --git a/packages/dify-ui/src/checkbox/index.tsx b/packages/dify-ui/src/checkbox/index.tsx new file mode 100644 index 0000000000..5e099be500 --- /dev/null +++ b/packages/dify-ui/src/checkbox/index.tsx @@ -0,0 +1,100 @@ +'use client' + +import type { Checkbox as BaseCheckboxNS } from '@base-ui/react/checkbox' +import type { HTMLAttributes } from 'react' +import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox' +import { cn } from '../cn' + +const checkboxRootClassName = cn( + 'inline-flex size-4 shrink-0 touch-manipulation items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3 transition-colors motion-reduce:transition-none', + 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked text-components-checkbox-icon', + 'hover:border-components-checkbox-border-hover hover:bg-components-checkbox-bg-unchecked-hover', + 'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-checkbox-bg focus-visible:ring-offset-0', + 'data-checked:border-transparent data-checked:bg-components-checkbox-bg data-checked:hover:bg-components-checkbox-bg-hover', + 'data-indeterminate:border-transparent data-indeterminate:bg-components-checkbox-bg data-indeterminate:hover:bg-components-checkbox-bg-hover', + 'data-disabled:cursor-not-allowed data-disabled:border-components-checkbox-border-disabled data-disabled:bg-components-checkbox-bg-disabled', + 'data-disabled:hover:border-components-checkbox-border-disabled data-disabled:hover:bg-components-checkbox-bg-disabled', + 'data-disabled:data-checked:border-transparent data-disabled:data-checked:bg-components-checkbox-bg-disabled-checked data-disabled:data-checked:text-components-checkbox-icon-disabled', + 'data-disabled:data-checked:hover:bg-components-checkbox-bg-disabled-checked', + 'data-disabled:data-indeterminate:border-transparent data-disabled:data-indeterminate:bg-components-checkbox-bg-disabled-checked data-disabled:data-indeterminate:text-components-checkbox-icon-disabled', + 'data-disabled:data-indeterminate:hover:bg-components-checkbox-bg-disabled-checked', +) + +const checkboxIndicatorClassName = 'flex size-3 items-center justify-center text-current data-unchecked:hidden' + +const checkboxSkeletonClassName = 'size-4 shrink-0 rounded-sm bg-text-quaternary opacity-20' + +export type CheckboxRootProps + = Omit + & { + className?: string + } + +export function CheckboxRoot({ + className, + ...props +}: CheckboxRootProps) { + return ( + + ) +} + +export type CheckboxIndicatorProps + = Omit + & { + className?: string + } + +export function CheckboxIndicator({ + className, + render, + ...props +}: CheckboxIndicatorProps) { + return ( + ( + + {state.indeterminate + ? + : } + + ))} + {...props} + /> + ) +} + +export type CheckboxProps + = Omit + +export function Checkbox({ + ...props +}: CheckboxProps) { + return ( + + + + ) +} + +export type CheckboxSkeletonProps + = Omit, 'className'> + & { + className?: string + } + +export function CheckboxSkeleton({ + className, + ...props +}: CheckboxSkeletonProps) { + return ( + + ) +} diff --git a/web/app/components/base/checkbox-list/__tests__/index.spec.tsx b/web/app/components/base/checkbox-list/__tests__/index.spec.tsx index 10c390962b..7d536a766a 100644 --- a/web/app/components/base/checkbox-list/__tests__/index.spec.tsx +++ b/web/app/components/base/checkbox-list/__tests__/index.spec.tsx @@ -1,8 +1,9 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import CheckboxList from '..' +import { CheckboxList } from '..' describe('checkbox list component', () => { + const selectAllName = 'common.operation.selectAll' const options = [ { label: 'Option 1', value: 'option1' }, { label: 'Option 2', value: 'option2' }, @@ -38,8 +39,7 @@ describe('checkbox list component', () => { it('renders select-all checkbox', () => { render() - const checkboxes = screen.getByTestId('checkbox-selectAll') - expect(checkboxes)!.toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: selectAllName })).toBeInTheDocument() }) it('selects all options when select-all is clicked', async () => { @@ -54,7 +54,7 @@ describe('checkbox list component', () => { />, ) - const selectAll = screen.getByTestId('checkbox-selectAll') + const selectAll = screen.getByRole('checkbox', { name: selectAllName }) await userEvent.click(selectAll) expect(onChange).toHaveBeenCalledWith(['option1', 'option2', 'option3', 'apple']) @@ -73,7 +73,7 @@ describe('checkbox list component', () => { />, ) - const selectAll = screen.getByTestId('checkbox-selectAll') + const selectAll = screen.getByRole('checkbox', { name: selectAllName }) await userEvent.click(selectAll) expect(onChange).not.toHaveBeenCalled() @@ -91,7 +91,7 @@ describe('checkbox list component', () => { />, ) - const selectAll = screen.getByTestId('checkbox-selectAll') + const selectAll = screen.getByRole('checkbox', { name: selectAllName }) await userEvent.click(selectAll) expect(onChange).toHaveBeenCalledWith([]) @@ -109,14 +109,14 @@ describe('checkbox list component', () => { />, ) - const selectAll = screen.getByTestId('checkbox-selectAll') - expect(selectAll.querySelector('[data-testid="check-icon-selectAll"]'))!.toBeInTheDocument() + const selectAll = screen.getByRole('checkbox', { name: selectAllName }) + expect(selectAll).toHaveAttribute('aria-checked', 'true') }) it('hides select-all checkbox when searching', async () => { render() await userEvent.type(screen.getByRole('textbox'), 'app') - expect(screen.queryByTestId('checkbox-selectAll')).not.toBeInTheDocument() + expect(screen.queryByRole('checkbox', { name: selectAllName })).not.toBeInTheDocument() }) it('selects options when checkbox is clicked', async () => { @@ -131,7 +131,7 @@ describe('checkbox list component', () => { />, ) - const selectOption = screen.getByTestId('checkbox-option1') + const selectOption = screen.getByRole('checkbox', { name: 'Option 1' }) await userEvent.click(selectOption) expect(onChange).toHaveBeenCalledWith(['option1']) }) @@ -148,7 +148,7 @@ describe('checkbox list component', () => { />, ) - const selectOption = screen.getByTestId('checkbox-option1') + const selectOption = screen.getByRole('checkbox', { name: 'Option 1' }) await userEvent.click(selectOption) expect(onChange).toHaveBeenCalledWith([]) }) @@ -165,7 +165,7 @@ describe('checkbox list component', () => { />, ) - const selectOption = screen.getByTestId('checkbox-option1') + const selectOption = screen.getByRole('checkbox', { name: 'Option 1' }) await userEvent.click(selectOption) expect(onChange).not.toHaveBeenCalled() }) @@ -202,12 +202,12 @@ describe('checkbox list component', () => { />, ) - const disabledCheckbox = screen.getByTestId('checkbox-disabled') + const disabledCheckbox = screen.getByRole('checkbox', { name: 'Disabled' }) await userEvent.click(disabledCheckbox) expect(onChange).not.toHaveBeenCalled() }) - it('does not toggle option when component is disabled and option is clicked via div', async () => { + it('does not toggle option when component is disabled and option label is clicked', async () => { const onChange = vi.fn() render( @@ -219,11 +219,7 @@ describe('checkbox list component', () => { />, ) - // Find option and click the div container - const optionLabels = screen.getAllByText('Option 1') - const optionDiv = optionLabels[0]!.closest('[data-testid="option-item"]') - expect(optionDiv)!.toBeInTheDocument() - await userEvent.click(optionDiv as HTMLElement) + await userEvent.click(screen.getByText('Option 1')) expect(onChange).not.toHaveBeenCalled() }) @@ -246,7 +242,7 @@ describe('checkbox list component', () => { showSearch={false} />, ) - expect(screen.queryByTestId('checkbox-selectAll')).not.toBeInTheDocument() + expect(screen.queryByRole('checkbox', { name: selectAllName })).not.toBeInTheDocument() options.forEach((option) => { expect(screen.getByText(option.label))!.toBeInTheDocument() }) @@ -284,7 +280,7 @@ describe('checkbox list component', () => { />, ) // When some but not all options are selected, clicking select-all should select all remaining options - const selectAll = screen.getByTestId('checkbox-selectAll') + const selectAll = screen.getByRole('checkbox', { name: selectAllName }) expect(selectAll)!.toBeInTheDocument() expect(selectAll)!.toHaveAttribute('aria-checked', 'mixed') @@ -326,7 +322,7 @@ describe('checkbox list component', () => { ) const optionLabel = screen.getByText('Option 1') - const optionRow = optionLabel.closest('div[data-testid="option-item"]') + const optionRow = optionLabel.closest('label[data-testid="option-item"]') expect(optionRow)!.toBeInTheDocument() await userEvent.click(optionRow as HTMLElement) @@ -347,7 +343,7 @@ describe('checkbox list component', () => { />, ) - const optionRow = screen.getByText('Option 1').closest('div[data-testid="option-item"]') + const optionRow = screen.getByText('Option 1').closest('label[data-testid="option-item"]') expect(optionRow)!.toBeInTheDocument() await userEvent.click(optionRow as HTMLElement) @@ -404,7 +400,7 @@ describe('checkbox list component', () => { />, ) - const checkbox = screen.getByTestId('checkbox-option') + const checkbox = screen.getByRole('checkbox', { name: 'Option' }) await userEvent.click(checkbox) expect(onChange).not.toHaveBeenCalled() }) diff --git a/web/app/components/base/checkbox-list/index.tsx b/web/app/components/base/checkbox-list/index.tsx index e6b2f5b260..22cd1a5718 100644 --- a/web/app/components/base/checkbox-list/index.tsx +++ b/web/app/components/base/checkbox-list/index.tsx @@ -1,11 +1,11 @@ 'use client' -import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' +import { Checkbox } from '@langgenius/dify-ui/checkbox' +import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group' import { cn } from '@langgenius/dify-ui/cn' -import { useCallback, useMemo, useState } from 'react' +import { useId, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' -import Checkbox from '@/app/components/base/checkbox' import SearchInput from '@/app/components/base/search-input' import SearchMenu from '@/assets/search-menu.svg' @@ -30,7 +30,7 @@ type CheckboxListProps = { maxHeight?: string | number } -const CheckboxList: FC = ({ +export const CheckboxList = ({ title = '', label, description, @@ -43,8 +43,9 @@ const CheckboxList: FC = ({ showCount = true, showSearch = true, maxHeight, -}) => { +}: CheckboxListProps) => { const { t } = useTranslation() + const groupLabelId = useId() const [searchQuery, setSearchQuery] = useState('') const filteredOptions = useMemo(() => { @@ -59,48 +60,15 @@ const CheckboxList: FC = ({ const selectedCount = value.length - const isAllSelected = useMemo(() => { - const selectableOptions = options.filter(option => !option.disabled) - return selectableOptions.length > 0 && selectableOptions.every(option => value.includes(option.value)) - }, [options, value]) - - const isIndeterminate = useMemo(() => { - const selectableOptions = options.filter(option => !option.disabled) - const selectedCount = selectableOptions.filter(option => value.includes(option.value)).length - return selectedCount > 0 && selectedCount < selectableOptions.length - }, [options, value]) - - const handleSelectAll = useCallback(() => { - if (disabled) - return - - if (isAllSelected) { - // Deselect all - onChange?.([]) - } - else { - // Select all non-disabled options - const allValues = options - .filter(option => !option.disabled) - .map(option => option.value) - onChange?.(allValues) - } - }, [isAllSelected, options, onChange, disabled]) - - const handleToggleOption = useCallback((optionValue: string) => { - if (disabled) - return - - const newValue = value.includes(optionValue) - ? value.filter(v => v !== optionValue) - : [...value, optionValue] - onChange?.(newValue) - }, [value, onChange, disabled]) + const selectableOptionValues = useMemo( + () => options.filter(option => !option.disabled).map(option => option.value), + [options], + ) return ( {label && ( - + {label} )} @@ -110,17 +78,24 @@ const CheckboxList: FC = ({ )} - + onChange?.(nextValue)} + allValues={selectableOptionValues} + disabled={disabled} + className="rounded-lg border border-components-panel-border bg-components-panel-bg" + > {(showSelectAll || title || showSearch) && ( {!searchQuery && showSelectAll && ( - + + + {t('operation.selectAll', { ns: 'common' })} + )} {!searchQuery ? ( @@ -177,45 +152,30 @@ const CheckboxList: FC = ({ ) : ( - filteredOptions.map((option) => { - const selected = value.includes(option.value) - - return ( - { - if (!option.disabled && !disabled) - handleToggleOption(option.value) - }} + filteredOptions.map(option => ( + + + - { - if (!option.disabled && !disabled) - handleToggleOption(option.value) - }} - disabled={option.disabled || disabled} - id={option.value} - /> - - {option.label} - - - ) - }) + {option.label} + + + )) )} - + ) } - -export default CheckboxList diff --git a/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx b/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx index 8801fb62da..715a27a408 100644 --- a/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx +++ b/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx @@ -394,8 +394,8 @@ describe('BaseField', () => { fireEvent.click(screen.getByText('Feature B')) }) - const checkboxB = screen.getByTestId('checkbox-b') - expect(checkboxB).toBeChecked() + const checkboxB = screen.getByRole('checkbox', { name: 'Feature B' }) + expect(checkboxB).toHaveAttribute('aria-checked', 'true') }) it('should handle dynamic select error state', () => { diff --git a/web/app/components/base/form/components/base/base-field.tsx b/web/app/components/base/form/components/base/base-field.tsx index da76e1f96f..2fd65e1202 100644 --- a/web/app/components/base/form/components/base/base-field.tsx +++ b/web/app/components/base/form/components/base/base-field.tsx @@ -18,7 +18,7 @@ import { useMemo, } from 'react' import { useTranslation } from 'react-i18next' -import CheckboxList from '@/app/components/base/checkbox-list' +import { CheckboxList } from '@/app/components/base/checkbox-list' import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types' import { Infotip } from '@/app/components/base/infotip' import Input from '@/app/components/base/input' diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx index d3c3581513..8535528b48 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx @@ -1,5 +1,6 @@ import type { MetadataItemInBatchEdit, MetadataItemWithEdit } from '../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import { DataType, UpdateType } from '../../types' import EditMetadataBatchModal from '../modal' @@ -212,6 +213,7 @@ describe('EditMetadataBatchModal', () => { }) it('should toggle apply to all checkbox', async () => { + const user = userEvent.setup() render() await waitFor(() => { @@ -219,7 +221,7 @@ describe('EditMetadataBatchModal', () => { }) const checkbox = screen.getByRole('checkbox', { name: 'dataset.metadata.batchEditMetadata.applyToAllSelectDocument' }) - fireEvent.click(checkbox) + await user.click(checkbox) await waitFor(() => { expect(checkbox).toHaveAttribute('aria-checked', 'true') @@ -482,6 +484,7 @@ describe('EditMetadataBatchModal', () => { }) it('should pass isApplyToAllSelectDocument as true when checked', async () => { + const user = userEvent.setup() const onSave = vi.fn() render() @@ -489,7 +492,7 @@ describe('EditMetadataBatchModal', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - fireEvent.click(screen.getByRole('checkbox', { name: 'dataset.metadata.batchEditMetadata.applyToAllSelectDocument' })) + await user.click(screen.getByRole('checkbox', { name: 'dataset.metadata.batchEditMetadata.applyToAllSelectDocument' })) fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx index 964f48c152..10f5e5cbc4 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { BuiltInMetadataItem, MetadataItemInBatchEdit, MetadataItemWithEdit } from '../types' import { Button } from '@langgenius/dify-ui/button' +import { Checkbox } from '@langgenius/dify-ui/checkbox' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { produce } from 'immer' @@ -10,7 +11,6 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import { useCreateMetaData } from '@/service/knowledge/use-metadata' -import Checkbox from '../../../base/checkbox' import { Infotip } from '../../../base/infotip' import useCheckMetadataName from '../hooks/use-check-metadata-name' import { DatasetMetadataPicker } from '../metadata-dataset/dataset-metadata-picker' @@ -131,13 +131,15 @@ const EditMetadataBatchModal: FC = ({ datasetId, documentNum, list, onSav - setIsApplyToAllSelectDocument(!isApplyToAllSelectDocument)} - id="apply-to-all" - ariaLabel={t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })} - /> - {t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })} + + + + {t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })} + +