dify
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
const IndeterminateIcon = () => {
|
||||
return (
|
||||
<div data-testid='indeterminate-icon'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2.5 6H9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IndeterminateIcon
|
||||
67
dify/web/app/components/base/checkbox/index.spec.tsx
Normal file
67
dify/web/app/components/base/checkbox/index.spec.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Checkbox from './index'
|
||||
|
||||
describe('Checkbox Component', () => {
|
||||
const mockProps = {
|
||||
id: 'test',
|
||||
}
|
||||
|
||||
it('renders unchecked checkbox by default', () => {
|
||||
render(<Checkbox {...mockProps} />)
|
||||
const checkbox = screen.getByTestId('checkbox-test')
|
||||
expect(checkbox).toBeInTheDocument()
|
||||
expect(checkbox).not.toHaveClass('bg-components-checkbox-bg')
|
||||
})
|
||||
|
||||
it('renders checked checkbox when checked prop is true', () => {
|
||||
render(<Checkbox {...mockProps} checked />)
|
||||
const checkbox = screen.getByTestId('checkbox-test')
|
||||
expect(checkbox).toHaveClass('bg-components-checkbox-bg')
|
||||
expect(screen.getByTestId('check-icon-test')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders indeterminate state correctly', () => {
|
||||
render(<Checkbox {...mockProps} indeterminate />)
|
||||
expect(screen.getByTestId('indeterminate-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles click events when not disabled', () => {
|
||||
const onCheck = jest.fn()
|
||||
render(<Checkbox {...mockProps} onCheck={onCheck} />)
|
||||
const checkbox = screen.getByTestId('checkbox-test')
|
||||
|
||||
fireEvent.click(checkbox)
|
||||
expect(onCheck).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not handle click events when disabled', () => {
|
||||
const onCheck = jest.fn()
|
||||
render(<Checkbox {...mockProps} disabled onCheck={onCheck} />)
|
||||
const checkbox = screen.getByTestId('checkbox-test')
|
||||
|
||||
fireEvent.click(checkbox)
|
||||
expect(onCheck).not.toHaveBeenCalled()
|
||||
expect(checkbox).toHaveClass('cursor-not-allowed')
|
||||
})
|
||||
|
||||
it('applies custom className when provided', () => {
|
||||
const customClass = 'custom-class'
|
||||
render(<Checkbox {...mockProps} className={customClass} />)
|
||||
const checkbox = screen.getByTestId('checkbox-test')
|
||||
expect(checkbox).toHaveClass(customClass)
|
||||
})
|
||||
|
||||
it('applies correct styles for disabled checked state', () => {
|
||||
render(<Checkbox {...mockProps} checked disabled />)
|
||||
const checkbox = screen.getByTestId('checkbox-test')
|
||||
expect(checkbox).toHaveClass('bg-components-checkbox-bg-disabled-checked')
|
||||
expect(checkbox).toHaveClass('cursor-not-allowed')
|
||||
})
|
||||
|
||||
it('applies correct styles for disabled unchecked state', () => {
|
||||
render(<Checkbox {...mockProps} disabled />)
|
||||
const checkbox = screen.getByTestId('checkbox-test')
|
||||
expect(checkbox).toHaveClass('bg-components-checkbox-bg-disabled')
|
||||
expect(checkbox).toHaveClass('cursor-not-allowed')
|
||||
})
|
||||
})
|
||||
394
dify/web/app/components/base/checkbox/index.stories.tsx
Normal file
394
dify/web/app/components/base/checkbox/index.stories.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Checkbox from '.'
|
||||
|
||||
// Helper function for toggling items in an array
|
||||
const createToggleItem = <T extends { id: string; checked: boolean }>(
|
||||
items: T[],
|
||||
setItems: (items: T[]) => void,
|
||||
) => (id: string) => {
|
||||
setItems(items.map(item =>
|
||||
item.id === id ? { ...item, checked: !item.checked } as T : item,
|
||||
))
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/Checkbox',
|
||||
component: Checkbox,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Checkbox component with support for checked, unchecked, indeterminate, and disabled states.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
checked: {
|
||||
control: 'boolean',
|
||||
description: 'Checked state',
|
||||
},
|
||||
indeterminate: {
|
||||
control: 'boolean',
|
||||
description: 'Indeterminate state (partially checked)',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes',
|
||||
},
|
||||
id: {
|
||||
control: 'text',
|
||||
description: 'HTML id attribute',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Checkbox>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const CheckboxDemo = (args: any) => {
|
||||
const [checked, setChecked] = useState(args.checked || false)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
{...args}
|
||||
checked={checked}
|
||||
onCheck={() => {
|
||||
if (!args.disabled) {
|
||||
setChecked(!checked)
|
||||
console.log('Checkbox toggled:', !checked)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{checked ? 'Checked' : 'Unchecked'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default unchecked
|
||||
export const Default: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
indeterminate: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Checked state
|
||||
export const Checked: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: true,
|
||||
disabled: false,
|
||||
indeterminate: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Indeterminate state
|
||||
export const Indeterminate: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
indeterminate: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled unchecked
|
||||
export const DisabledUnchecked: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: true,
|
||||
indeterminate: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled checked
|
||||
export const DisabledChecked: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: true,
|
||||
disabled: true,
|
||||
indeterminate: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled indeterminate
|
||||
export const DisabledIndeterminate: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: true,
|
||||
indeterminate: true,
|
||||
},
|
||||
}
|
||||
|
||||
// State comparison
|
||||
export const StateComparison: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={false} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Unchecked</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={true} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Checked</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={false} indeterminate={true} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Indeterminate</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={false} disabled={true} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Disabled</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={true} disabled={true} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Disabled Checked</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={false} indeterminate={true} disabled={true} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Disabled Indeterminate</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// With labels
|
||||
const WithLabelsDemo = () => {
|
||||
const [items, setItems] = useState([
|
||||
{ id: '1', label: 'Enable notifications', checked: true },
|
||||
{ id: '2', label: 'Enable email updates', checked: false },
|
||||
{ id: '3', label: 'Enable SMS alerts', checked: false },
|
||||
])
|
||||
|
||||
const toggleItem = createToggleItem(items, setItems)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map(item => (
|
||||
<div key={item.id} className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={item.id}
|
||||
checked={item.checked}
|
||||
onCheck={() => toggleItem(item.id)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={item.id}
|
||||
className="cursor-pointer text-sm text-gray-700"
|
||||
onClick={() => toggleItem(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithLabels: Story = {
|
||||
render: () => <WithLabelsDemo />,
|
||||
}
|
||||
|
||||
// Select all example
|
||||
const SelectAllExampleDemo = () => {
|
||||
const [items, setItems] = useState([
|
||||
{ id: '1', label: 'Item 1', checked: false },
|
||||
{ id: '2', label: 'Item 2', checked: false },
|
||||
{ id: '3', label: 'Item 3', checked: false },
|
||||
])
|
||||
|
||||
const allChecked = items.every(item => item.checked)
|
||||
const someChecked = items.some(item => item.checked)
|
||||
const indeterminate = someChecked && !allChecked
|
||||
|
||||
const toggleAll = () => {
|
||||
const newChecked = !allChecked
|
||||
setItems(items.map(item => ({ ...item, checked: newChecked })))
|
||||
}
|
||||
|
||||
const toggleItem = createToggleItem(items, setItems)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-lg bg-gray-50 p-4">
|
||||
<div className="flex items-center gap-3 border-b border-gray-200 pb-3">
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
indeterminate={indeterminate}
|
||||
onCheck={toggleAll}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Select All</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 pl-7">
|
||||
{items.map(item => (
|
||||
<div key={item.id} className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={item.id}
|
||||
checked={item.checked}
|
||||
onCheck={() => toggleItem(item.id)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={item.id}
|
||||
className="cursor-pointer text-sm text-gray-600"
|
||||
onClick={() => toggleItem(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SelectAllExample: Story = {
|
||||
render: () => <SelectAllExampleDemo />,
|
||||
}
|
||||
|
||||
// Form example
|
||||
const FormExampleDemo = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
terms: false,
|
||||
newsletter: false,
|
||||
privacy: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-96 rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Account Settings</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={formData.terms}
|
||||
onCheck={() => setFormData({ ...formData, terms: !formData.terms })}
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="terms" className="cursor-pointer text-sm font-medium text-gray-700">
|
||||
I agree to the terms and conditions
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Required to continue
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="newsletter"
|
||||
checked={formData.newsletter}
|
||||
onCheck={() => setFormData({ ...formData, newsletter: !formData.newsletter })}
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="newsletter" className="cursor-pointer text-sm font-medium text-gray-700">
|
||||
Subscribe to newsletter
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Get updates about new features
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="privacy"
|
||||
checked={formData.privacy}
|
||||
onCheck={() => setFormData({ ...formData, privacy: !formData.privacy })}
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="privacy" className="cursor-pointer text-sm font-medium text-gray-700">
|
||||
I have read the privacy policy
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Required to continue
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FormExample: Story = {
|
||||
render: () => <FormExampleDemo />,
|
||||
}
|
||||
|
||||
// Task list example
|
||||
const TaskListExampleDemo = () => {
|
||||
const [tasks, setTasks] = useState([
|
||||
{ id: '1', title: 'Review pull request', completed: true },
|
||||
{ id: '2', title: 'Update documentation', completed: true },
|
||||
{ id: '3', title: 'Fix navigation bug', completed: false },
|
||||
{ id: '4', title: 'Deploy to staging', completed: false },
|
||||
])
|
||||
|
||||
const toggleTask = (id: string) => {
|
||||
setTasks(tasks.map(task =>
|
||||
task.id === id ? { ...task, completed: !task.completed } : task,
|
||||
))
|
||||
}
|
||||
|
||||
const completedCount = tasks.filter(t => t.completed).length
|
||||
|
||||
return (
|
||||
<div className="w-96 rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Today's Tasks</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{completedCount} of {tasks.length} completed
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{tasks.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 rounded p-2 hover:bg-gray-50"
|
||||
>
|
||||
<Checkbox
|
||||
id={task.id}
|
||||
checked={task.completed}
|
||||
onCheck={() => toggleTask(task.id)}
|
||||
/>
|
||||
<span
|
||||
className={`cursor-pointer text-sm ${
|
||||
task.completed ? 'text-gray-400 line-through' : 'text-gray-700'
|
||||
}`}
|
||||
onClick={() => toggleTask(task.id)}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TaskListExample: Story = {
|
||||
render: () => <TaskListExampleDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
indeterminate: false,
|
||||
disabled: false,
|
||||
id: 'playground-checkbox',
|
||||
},
|
||||
}
|
||||
51
dify/web/app/components/base/checkbox/index.tsx
Normal file
51
dify/web/app/components/base/checkbox/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { RiCheckLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import IndeterminateIcon from './assets/indeterminate-icon'
|
||||
|
||||
type CheckboxProps = {
|
||||
id?: string
|
||||
checked?: boolean
|
||||
onCheck?: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
indeterminate?: boolean
|
||||
}
|
||||
|
||||
const Checkbox = ({
|
||||
id,
|
||||
checked,
|
||||
onCheck,
|
||||
className,
|
||||
disabled,
|
||||
indeterminate,
|
||||
}: CheckboxProps) => {
|
||||
const checkClassName = (checked || indeterminate)
|
||||
? 'bg-components-checkbox-bg text-components-checkbox-icon hover:bg-components-checkbox-bg-hover'
|
||||
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked hover:bg-components-checkbox-bg-unchecked-hover hover:border-components-checkbox-border-hover'
|
||||
const disabledClassName = (checked || indeterminate)
|
||||
? 'cursor-not-allowed bg-components-checkbox-bg-disabled-checked text-components-checkbox-icon-disabled hover:bg-components-checkbox-bg-disabled-checked'
|
||||
: 'cursor-not-allowed border-components-checkbox-border-disabled bg-components-checkbox-bg-disabled hover:border-components-checkbox-border-disabled hover:bg-components-checkbox-bg-disabled'
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={cn(
|
||||
'flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-[4px] shadow-xs shadow-shadow-shadow-3',
|
||||
checkClassName,
|
||||
disabled && disabledClassName,
|
||||
className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
if (disabled)
|
||||
return
|
||||
onCheck?.(event)
|
||||
}}
|
||||
data-testid={`checkbox-${id}`}
|
||||
>
|
||||
{!checked && indeterminate && <IndeterminateIcon />}
|
||||
{checked && <RiCheckLine className='h-3 w-3' data-testid={`check-icon-${id}`} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Checkbox
|
||||
Reference in New Issue
Block a user