feat(dify-ui): add Checkbox/CheckboxGroup primitives (#36271)

This commit is contained in:
yyh 2026-05-18 10:01:56 +08:00 committed by GitHub
parent b96f372f45
commit 6649e4025e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 670 additions and 125 deletions

View File

@ -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"

View File

@ -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 (
<CheckboxGroup value={value} onValueChange={setValue} allValues={['read', 'write']}>
<Checkbox parent aria-label="All permissions" />
<label>
<Checkbox value="read" />
Read
</label>
<label>
<Checkbox value="write" />
Write
</label>
</CheckboxGroup>
)
}
const screen = await render(<PermissionsDemo />)
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(
<Field.Root name="features">
<Fieldset.Root render={<CheckboxGroup value={['search']} onValueChange={onValueChange} />}>
<Fieldset.Legend>Features</Fieldset.Legend>
<Field.Item>
<Field.Label>
<Checkbox value="search" />
Search
</Field.Label>
</Field.Item>
<Field.Item>
<Field.Label>
<Checkbox value="analytics" />
Analytics
</Field.Label>
</Field.Item>
</Fieldset.Root>
</Field.Root>,
)
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'])
})
})

View File

@ -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<typeof CheckboxGroup>
export default meta
type Story = StoryObj<typeof meta>
function DocumentSelectionDemo() {
const documentIds = ['doc-1', 'doc-2', 'doc-3']
const [selected, setSelected] = useState<string[]>(['doc-1'])
const groupLabelId = useId()
return (
<CheckboxGroup
aria-labelledby={groupLabelId}
value={selected}
onValueChange={setSelected}
allValues={documentIds}
className="flex flex-col gap-3"
>
<label id={groupLabelId} className="flex items-center gap-2 system-sm-semibold-uppercase text-text-secondary">
<Checkbox parent />
Current page documents
</label>
<div className="flex flex-col gap-2 pl-6">
{[
{ id: 'doc-1', name: 'onboarding-guide.pdf' },
{ id: 'doc-2', name: 'pricing-faq.md' },
{ id: 'doc-3', name: 'release-notes.txt' },
].map(document => (
<label key={document.id} className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox value={document.id} />
{document.name}
</label>
))}
</div>
</CheckboxGroup>
)
}
export const DocumentSelection: Story = {
render: () => <DocumentSelectionDemo />,
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<string[]>(['markdown'])
return (
<Field.Root name="allowed_file_types" className="flex w-80 flex-col gap-2">
<Field.Description className="body-xs-regular text-text-tertiary">
This mirrors Dify dynamic form fields where checkbox options are controlled by schema and persisted as a string array.
</Field.Description>
<Fieldset.Root
render={(
<CheckboxGroup
value={selected}
onValueChange={setSelected}
className="flex flex-col gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg p-3"
/>
)}
>
<Fieldset.Legend className="system-sm-medium text-text-secondary">
Allowed file types
</Fieldset.Legend>
{options.map(option => (
<Field.Item key={option.value}>
<Field.Label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox value={option.value} />
{option.label}
</Field.Label>
</Field.Item>
))}
</Fieldset.Root>
</Field.Root>
)
}
export const DynamicFormField: Story = {
render: () => <DynamicFormFieldDemo />,
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.',
},
},
},
}

View File

@ -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 <BaseCheckboxGroup {...props} />
}

View File

@ -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(<Checkbox checked={false} aria-label="Accept terms" />)
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(<Checkbox checked aria-label="Accept terms" />)
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(<Checkbox checked={false} indeterminate aria-label="Select all" />)
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(
<Checkbox checked={false} aria-label="Accept terms" onCheckedChange={onCheckedChange} />,
)
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(
<Checkbox checked={false} aria-label="Accept terms" onCheckedChange={onCheckedChange} />,
)
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(<Checkbox checked aria-label="Accept terms" onCheckedChange={onCheckedChange} />)
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(
<Checkbox checked={false} disabled aria-label="Accept terms" onCheckedChange={onCheckedChange} />,
)
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(
<form>
<Checkbox
checked
name="terms"
value="accepted"
uncheckedValue="declined"
aria-label="Terms"
/>
<Checkbox
checked={false}
name="newsletter"
value="yes"
uncheckedValue="no"
aria-label="Newsletter"
/>
</form>,
)
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(
<CheckboxRoot checked aria-label="Custom checkbox" className="custom-root">
<CheckboxIndicator className="custom-indicator" />
</CheckboxRoot>,
)
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(<CheckboxSkeleton data-testid="checkbox-skeleton" />)
expect(screen.container.querySelector('[role="checkbox"]')).not.toBeInTheDocument()
await expect.element(screen.getByTestId('checkbox-skeleton')).toHaveClass('bg-text-quaternary', 'opacity-20')
})
})

View File

@ -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<typeof Checkbox>
export default meta
type Story = StoryObj<typeof meta>
function CheckboxDemo(args: Partial<ComponentProps<typeof Checkbox>>) {
const [checked, setChecked] = useState(args.checked ?? false)
return (
<label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox
{...args}
checked={checked}
onCheckedChange={setChecked}
/>
Enable feature
</label>
)
}
export const Default: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: false,
indeterminate: false,
disabled: false,
},
}
export const Checked: Story = {
render: args => <CheckboxDemo {...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: () => (
<div className="flex flex-col gap-3">
<label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox checked={false} disabled />
Disabled unchecked
</label>
<label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox checked disabled />
Disabled checked
</label>
<label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox checked={false} indeterminate disabled />
Disabled mixed
</label>
</div>
),
}
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 (
<div className="flex flex-col gap-3">
{states.map(state => (
<label key={state.label} className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox
checked={state.checked}
indeterminate={state.indeterminate}
disabled={state.disabled}
/>
{state.label}
</label>
))}
<div className="flex items-center gap-2 system-sm-medium text-text-secondary">
<CheckboxSkeleton aria-hidden="true" />
Skeleton
</div>
</div>
)
}
export const StateMatrix: Story = {
render: () => <StateMatrixDemo />,
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.',
},
},
},
}

View File

@ -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<BaseCheckboxNS.Root.Props, 'className'>
& {
className?: string
}
export function CheckboxRoot({
className,
...props
}: CheckboxRootProps) {
return (
<BaseCheckbox.Root
className={cn(checkboxRootClassName, className)}
{...props}
/>
)
}
export type CheckboxIndicatorProps
= Omit<BaseCheckboxNS.Indicator.Props, 'className' | 'children'>
& {
className?: string
}
export function CheckboxIndicator({
className,
render,
...props
}: CheckboxIndicatorProps) {
return (
<BaseCheckbox.Indicator
className={cn(checkboxIndicatorClassName, className)}
render={render ?? ((indicatorProps, state) => (
<span {...indicatorProps}>
{state.indeterminate
? <span className="block h-[1.5px] w-1.75 rounded-full bg-current" />
: <span className="i-ri-check-line block size-3 shrink-0" />}
</span>
))}
{...props}
/>
)
}
export type CheckboxProps
= Omit<CheckboxRootProps, 'children'>
export function Checkbox({
...props
}: CheckboxProps) {
return (
<CheckboxRoot {...props}>
<CheckboxIndicator />
</CheckboxRoot>
)
}
export type CheckboxSkeletonProps
= Omit<HTMLAttributes<HTMLDivElement>, 'className'>
& {
className?: string
}
export function CheckboxSkeleton({
className,
...props
}: CheckboxSkeletonProps) {
return (
<div
className={cn(checkboxSkeletonClassName, className)}
{...props}
/>
)
}

View File

@ -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(<CheckboxList options={options} showSelectAll />)
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(<CheckboxList options={options} />)
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()
})

View File

@ -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<CheckboxListProps> = ({
export const CheckboxList = ({
title = '',
label,
description,
@ -43,8 +43,9 @@ const CheckboxList: FC<CheckboxListProps> = ({
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<CheckboxListProps> = ({
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 (
<div className={cn('flex w-full flex-col gap-1', containerClassName)}>
{label && (
<div className="system-sm-medium text-text-secondary">
<div id={groupLabelId} className="system-sm-medium text-text-secondary">
{label}
</div>
)}
@ -110,17 +78,24 @@ const CheckboxList: FC<CheckboxListProps> = ({
</div>
)}
<div className="rounded-lg border border-components-panel-border bg-components-panel-bg">
<CheckboxGroup
aria-labelledby={label ? groupLabelId : undefined}
value={value}
onValueChange={nextValue => onChange?.(nextValue)}
allValues={selectableOptionValues}
disabled={disabled}
className="rounded-lg border border-components-panel-border bg-components-panel-bg"
>
{(showSelectAll || title || showSearch) && (
<div className="relative flex items-center gap-2 border-b border-divider-subtle px-3 py-2">
{!searchQuery && showSelectAll && (
<Checkbox
checked={isAllSelected}
indeterminate={isIndeterminate}
onCheck={handleSelectAll}
disabled={disabled}
id="selectAll"
/>
<label className={cn('flex shrink-0 items-center', !disabled && 'cursor-pointer')}>
<Checkbox
parent
disabled={disabled}
/>
<span className="sr-only">{t('operation.selectAll', { ns: 'common' })}</span>
</label>
)}
{!searchQuery
? (
@ -177,45 +152,30 @@ const CheckboxList: FC<CheckboxListProps> = ({
</div>
)
: (
filteredOptions.map((option) => {
const selected = value.includes(option.value)
return (
<div
key={option.value}
data-testid="option-item"
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover',
option.disabled && 'cursor-not-allowed opacity-50',
)}
onClick={() => {
if (!option.disabled && !disabled)
handleToggleOption(option.value)
}}
filteredOptions.map(option => (
<label
key={option.value}
data-testid="option-item"
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover',
(option.disabled || disabled) && 'cursor-not-allowed opacity-50',
)}
>
<Checkbox
value={option.value}
disabled={option.disabled || disabled}
/>
<span
className="flex-1 truncate system-sm-medium text-text-secondary"
title={option.label}
>
<Checkbox
checked={selected}
onCheck={() => {
if (!option.disabled && !disabled)
handleToggleOption(option.value)
}}
disabled={option.disabled || disabled}
id={option.value}
/>
<div
className="flex-1 truncate system-sm-medium text-text-secondary"
title={option.label}
>
{option.label}
</div>
</div>
)
})
{option.label}
</span>
</label>
))
)}
</div>
</div>
</CheckboxGroup>
</div>
)
}
export default CheckboxList

View File

@ -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', () => {

View File

@ -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'

View File

@ -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(<EditMetadataBatchModal {...defaultProps} />)
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(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
@ -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' }))

View File

@ -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<Props> = ({ datasetId, documentNum, list, onSav
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center select-none">
<Checkbox
checked={isApplyToAllSelectDocument}
onCheck={() => setIsApplyToAllSelectDocument(!isApplyToAllSelectDocument)}
id="apply-to-all"
ariaLabel={t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })}
/>
<div className="mr-1 ml-2 system-xs-medium text-text-secondary">{t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })}</div>
<label className="flex cursor-pointer items-center">
<Checkbox
checked={isApplyToAllSelectDocument}
onCheckedChange={setIsApplyToAllSelectDocument}
/>
<span className="mr-1 ml-2 system-xs-medium text-text-secondary">
{t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })}
</span>
</label>
<Infotip
aria-label={t(`${i18nPrefix}.applyToAllSelectDocumentTip`, { ns: 'dataset' })}
className="p-px"

View File

@ -8,7 +8,7 @@ import type { ToolWithProvider, ValueSelector, Var } from '@/app/components/work
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useEffect, useMemo, useState } from 'react'
import CheckboxList from '@/app/components/base/checkbox-list'
import { CheckboxList } from '@/app/components/base/checkbox-list'
import Input from '@/app/components/base/input'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { AppSelector } from '@/app/components/plugins/plugin-detail-panel/app-selector'