feat(ui): migrate radio to Base UI and update web callsites (#36451)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-05-20 20:05:31 +08:00 committed by GitHub
parent 7d0d9019d8
commit 9f9cb4d17e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 1605 additions and 2282 deletions

View File

@ -1574,26 +1574,6 @@
"count": 1
}
},
"web/app/components/base/radio-card/index.stories.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/radio/component/group/index.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/base/radio/context/index.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/radio/index.stories.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/search-input/index.stories.tsx": {
"no-console": {
"count": 3

View File

@ -73,6 +73,14 @@
"types": "./src/number-field/index.tsx",
"import": "./src/number-field/index.tsx"
},
"./radio": {
"types": "./src/radio/index.tsx",
"import": "./src/radio/index.tsx"
},
"./radio-group": {
"types": "./src/radio-group/index.tsx",
"import": "./src/radio-group/index.tsx"
},
"./popover": {
"types": "./src/popover/index.tsx",
"import": "./src/popover/index.tsx"

View File

@ -0,0 +1,82 @@
import { useState } from 'react'
import { render } from 'vitest-browser-react'
import { FieldItem, FieldLabel, FieldRoot } from '../../field'
import { FieldsetLegend, FieldsetRoot } from '../../fieldset'
import { Radio } from '../../radio'
import { RadioGroup } from '../index'
const clickElement = (element: HTMLElement | SVGElement) => {
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
}
describe('RadioGroup', () => {
it('should manage a controlled single selection', async () => {
function StorageDemo() {
const [value, setValue] = useState('ssd')
return (
<FieldRoot name="storageType">
<FieldsetRoot render={<RadioGroup value={value} onValueChange={setValue} />}>
<FieldsetLegend>Storage type</FieldsetLegend>
<FieldItem>
<FieldLabel>
<Radio value="ssd" />
SSD
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel>
<Radio value="hdd" />
HDD
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>
)
}
const screen = await render(<StorageDemo />)
await expect.element(screen.getByRole('radio', { name: 'SSD' })).toHaveAttribute('aria-checked', 'true')
clickElement(screen.getByRole('radio', { name: 'HDD' }).element())
await vi.waitFor(async () => {
await expect.element(screen.getByRole('radio', { name: 'SSD' })).toHaveAttribute('aria-checked', 'false')
await expect.element(screen.getByRole('radio', { name: 'HDD' })).toHaveAttribute('aria-checked', 'true')
})
})
it('should compose with Dify UI Field and Fieldset without losing labels', async () => {
const onValueChange = vi.fn()
const screen = await render(
<FieldRoot name="storageType">
<FieldsetRoot render={<RadioGroup value="ssd" onValueChange={onValueChange} />}>
<FieldsetLegend>Storage type</FieldsetLegend>
<FieldItem>
<FieldLabel>
<Radio value="ssd" />
SSD
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel>
<Radio value="hdd" />
HDD
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>,
)
await expect.element(screen.getByRole('radiogroup', { name: 'Storage type' })).toBeInTheDocument()
const hdd = screen.getByRole('radio', { name: 'HDD' })
await expect.element(hdd).toHaveAttribute('aria-checked', 'false')
clickElement(hdd.element())
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(onValueChange.mock.calls[0]?.[0]).toBe('hdd')
})
})

View File

@ -0,0 +1,217 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useState } from 'react'
import { RadioGroup } from '.'
import {
FieldDescription,
FieldItem,
FieldLabel,
FieldRoot,
} from '../field'
import { FieldsetLegend, FieldsetRoot } from '../fieldset'
import { Radio, RadioControl, RadioRoot } from '../radio'
const meta = {
title: 'Base/Form/RadioGroup',
component: RadioGroup,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'RadioGroup primitive built on Base UI. For normal form rows, compose FieldRoot, FieldsetRoot, FieldLabel, RadioGroup, and Radio. For option cards, make the card itself a RadioRoot with variant="unstyled" and render RadioControl inside it.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof RadioGroup>
export default meta
type Story = StoryObj<typeof meta>
function StandardFormRowsDemo() {
const [value, setValue] = useState('vector')
return (
<FieldRoot name="retrievalIndex" className="w-80">
<FieldsetRoot
render={(
<RadioGroup value={value} onValueChange={setValue} className="flex-col items-start gap-3" />
)}
>
<FieldsetLegend>Retrieval index</FieldsetLegend>
{[
{ value: 'vector', label: 'Vector storage' },
{ value: 'keyword', label: 'Keyword index' },
{ value: 'hybrid', label: 'Hybrid retrieval' },
].map(option => (
<FieldItem key={option.value}>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value={option.value} />
{option.label}
</FieldLabel>
</FieldItem>
))}
</FieldsetRoot>
</FieldRoot>
)
}
export const StandardFormRows: Story = {
render: () => <StandardFormRowsDemo />,
parameters: {
docs: {
description: {
story: 'Default form composition. Most product code should use this shape: RadioGroup owns value, FieldsetLegend names the group, and FieldLabel makes each row clickable.',
},
},
},
}
function BooleanInlineDemo() {
const [value, setValue] = useState(true)
return (
<FieldRoot name="streaming" className="w-80">
<FieldsetRoot
render={(
<RadioGroup<boolean> value={value} onValueChange={setValue} className="gap-3" />
)}
>
<FieldsetLegend>Streaming output</FieldsetLegend>
<div className="flex items-center gap-3">
<FieldItem>
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
<Radio value={true} />
True
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
<Radio value={false} />
False
</FieldLabel>
</FieldItem>
</div>
</FieldsetRoot>
</FieldRoot>
)
}
export const BooleanInline: Story = {
render: () => <BooleanInlineDemo />,
parameters: {
docs: {
description: {
story: 'Compact boolean radio fields. This is the pattern used by model parameters and dynamic boolean schema fields.',
},
},
},
}
function OptionCardsDemo() {
const [value, setValue] = useState('default')
return (
<FieldRoot name="promptMode" className="w-100">
<FieldsetRoot
render={(
<RadioGroup value={value} onValueChange={setValue} className="flex-col items-stretch gap-3" />
)}
>
<FieldsetLegend>Prompt mode</FieldsetLegend>
{[
{
value: 'default',
title: 'Default prompt',
description: 'Use the built-in prompt for consistent output.',
},
{
value: 'custom',
title: 'Custom prompt',
description: 'Write a prompt for this app and keep full control.',
},
].map(option => (
<RadioRoot
key={option.value}
value={option.value}
variant="unstyled"
nativeButton
render={<button type="button" />}
className="w-full rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg p-4 text-left transition-colors hover:bg-state-base-hover data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="system-sm-semibold text-text-primary">
{option.title}
</div>
<div className="mt-1 system-xs-regular text-text-tertiary">
{option.description}
</div>
</div>
<RadioControl aria-hidden="true" />
</div>
</RadioRoot>
))}
</FieldsetRoot>
</FieldRoot>
)
}
export const OptionCards: Story = {
render: () => <OptionCardsDemo />,
parameters: {
docs: {
description: {
story: 'Use RadioRoot with variant="unstyled" when the entire option card is the radio. RadioControl renders the visual dot inside the card.',
},
},
},
}
function DynamicFormFieldDemo() {
const options = [
{ value: 'automatic', label: 'Automatic' },
{ value: 'high_quality', label: 'High quality' },
{ value: 'economy', label: 'Economy' },
]
const [selected, setSelected] = useState('automatic')
return (
<FieldRoot name="generation_mode" className="flex w-80 flex-col gap-2">
<FieldDescription className="body-xs-regular text-text-tertiary">
This mirrors Dify dynamic form fields where radio options are controlled by schema and persisted as a single value.
</FieldDescription>
<FieldsetRoot
render={(
<RadioGroup
value={selected}
onValueChange={setSelected}
className="flex-col items-start gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg p-3"
/>
)}
>
<FieldsetLegend className="system-sm-medium text-text-secondary">
Generation mode
</FieldsetLegend>
{options.map(option => (
<FieldItem key={option.value}>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value={option.value} />
{option.label}
</FieldLabel>
</FieldItem>
))}
</FieldsetRoot>
</FieldRoot>
)
}
export const DynamicFormField: Story = {
render: () => <DynamicFormFieldDemo />,
parameters: {
docs: {
description: {
story: 'Matches Dify form composition: Field and Fieldset provide group labeling while RadioGroup owns controlled single-selection state.',
},
},
},
}

View File

@ -0,0 +1,23 @@
'use client'
import type { RadioGroup as BaseRadioGroupNS } from '@base-ui/react/radio-group'
import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group'
import { cn } from '../cn'
export type RadioGroupProps<Value = string>
= Omit<BaseRadioGroupNS.Props<Value>, 'className'>
& {
className?: string
}
export function RadioGroup<Value = string>({
className,
...props
}: RadioGroupProps<Value>) {
return (
<BaseRadioGroup
className={cn('flex items-center gap-2', className)}
{...props}
/>
)
}

View File

@ -0,0 +1,178 @@
import type { ComponentProps, ReactNode } from 'react'
import { render } from 'vitest-browser-react'
import { FieldItem, FieldLabel, FieldRoot } from '../../field'
import { FieldsetLegend, FieldsetRoot } from '../../fieldset'
import { RadioGroup } from '../../radio-group'
import {
Radio,
RadioControl,
RadioIndicator,
RadioRoot,
RadioSkeleton,
} from '../index'
const clickElement = (element: HTMLElement | SVGElement) => {
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
}
type TestRadioGroupProps = ComponentProps<typeof RadioGroup> & {
children: ReactNode
label: string
name?: string
}
function TestRadioGroup({
children,
label,
name = 'radioField',
...props
}: TestRadioGroupProps) {
return (
<FieldRoot name={name}>
<FieldsetRoot render={<RadioGroup {...props} />}>
<FieldsetLegend>{label}</FieldsetLegend>
{children}
</FieldsetRoot>
</FieldRoot>
)
}
type TestRadioOptionProps = ComponentProps<typeof Radio> & {
children: ReactNode
}
function TestRadioOption({
children,
...props
}: TestRadioOptionProps) {
return (
<FieldItem>
<FieldLabel>
<Radio {...props} />
{children}
</FieldLabel>
</FieldItem>
)
}
describe('Radio', () => {
it('should render unchecked and checked radios with Base UI semantics', async () => {
const screen = await render(
<TestRadioGroup defaultValue="ssd" label="Storage type">
<TestRadioOption value="ssd">SSD</TestRadioOption>
<TestRadioOption value="hdd">HDD</TestRadioOption>
</TestRadioGroup>,
)
const ssd = screen.getByRole('radio', { name: 'SSD' })
const hdd = screen.getByRole('radio', { name: 'HDD' })
await expect.element(ssd).toHaveAttribute('aria-checked', 'true')
await expect.element(ssd).toHaveAttribute('data-checked', '')
await expect.element(ssd).toHaveClass('data-checked:border-components-radio-border-checked')
await expect.element(hdd).toHaveAttribute('aria-checked', 'false')
await expect.element(hdd).toHaveAttribute('data-unchecked', '')
})
it('should call onValueChange and update uncontrolled state when selected', async () => {
const onValueChange = vi.fn()
const screen = await render(
<TestRadioGroup defaultValue="ssd" label="Storage type" onValueChange={onValueChange}>
<TestRadioOption value="ssd">SSD</TestRadioOption>
<TestRadioOption value="hdd">HDD</TestRadioOption>
</TestRadioGroup>,
)
clickElement(screen.getByRole('radio', { name: 'HDD' }).element())
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(onValueChange.mock.calls[0]?.[0]).toBe('hdd')
await expect.element(screen.getByRole('radio', { name: 'HDD' })).toHaveAttribute('aria-checked', 'true')
})
it('should ignore interaction when disabled', async () => {
const onValueChange = vi.fn()
const screen = await render(
<TestRadioGroup defaultValue="ssd" label="Storage type" onValueChange={onValueChange}>
<TestRadioOption value="ssd">SSD</TestRadioOption>
<TestRadioOption value="hdd" disabled>HDD</TestRadioOption>
</TestRadioGroup>,
)
const hdd = screen.getByRole('radio', { name: 'HDD' })
await expect.element(hdd).toHaveAttribute('data-disabled', '')
await expect.element(hdd).toHaveClass('data-disabled:cursor-not-allowed')
clickElement(hdd.element())
expect(onValueChange).not.toHaveBeenCalled()
await expect.element(hdd).toHaveAttribute('aria-checked', 'false')
})
it('should submit the selected group value through the hidden input', async () => {
const screen = await render(
<form>
<TestRadioGroup defaultValue="ssd" label="Storage type" name="storageType">
<TestRadioOption value="ssd">SSD</TestRadioOption>
<TestRadioOption value="hdd">HDD</TestRadioOption>
</TestRadioGroup>
</form>,
)
const form = screen.container.querySelector<HTMLFormElement>('form')
expect(form).not.toBeNull()
if (!form)
return
const data = new FormData(form)
expect(data.get('storageType')).toBe('ssd')
})
it('should support custom compound composition with RadioRoot and RadioIndicator', async () => {
const screen = await render(
<TestRadioGroup defaultValue="custom" label="Custom">
<FieldItem>
<FieldLabel>
<RadioRoot value="custom" className="custom-root">
<RadioIndicator className="custom-indicator" keepMounted />
</RadioRoot>
Custom
</FieldLabel>
</FieldItem>
</TestRadioGroup>,
)
await expect.element(screen.getByRole('radio', { name: 'Custom' })).toHaveClass('custom-root')
expect(screen.container.querySelector('.custom-indicator')).toBeInTheDocument()
})
it('should support unstyled roots with a visual RadioControl for option cards', async () => {
const screen = await render(
<RadioGroup defaultValue="card" aria-label="Card choice">
<RadioRoot
value="card"
variant="unstyled"
nativeButton
render={<button type="button" className="custom-card" />}
>
<span>Card option</span>
<RadioControl className="custom-control" />
</RadioRoot>
</RadioGroup>,
)
await expect.element(screen.getByRole('radio', { name: 'Card option' })).toHaveClass('custom-card')
expect(screen.container.querySelector('.custom-control')).toBeInTheDocument()
await expect.element(screen.getByRole('radio', { name: 'Card option' })).toHaveAttribute('data-checked', '')
})
})
describe('RadioSkeleton', () => {
it('should render a visual placeholder without radio semantics', async () => {
const screen = await render(<RadioSkeleton />)
const skeleton = screen.container.querySelector<HTMLElement>('.rounded-full')
expect(screen.container.querySelector('[role="radio"]')).not.toBeInTheDocument()
await expect.element(skeleton).toHaveClass('rounded-full', 'opacity-20')
})
})

View File

@ -0,0 +1,147 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ComponentProps } from 'react'
import { useState } from 'react'
import {
Radio,
RadioSkeleton,
} from '.'
import { FieldItem, FieldLabel, FieldRoot } from '../field'
import { FieldsetLegend, FieldsetRoot } from '../fieldset'
import { RadioGroup } from '../radio-group'
const meta = {
title: 'Base/Form/Radio',
component: Radio,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Radio primitive built on Base UI. It preserves RadioGroup selection, hidden input, disabled, and form semantics while applying the Dify 16px radio design from Figma. Import from `@langgenius/dify-ui/radio` and place radios inside `RadioGroup` from `@langgenius/dify-ui/radio-group`.',
},
},
},
tags: ['autodocs'],
args: {
disabled: false,
value: 'ssd',
},
argTypes: {
disabled: {
control: 'boolean',
description: 'Disables user interaction and exposes Base UI disabled state attributes.',
},
},
} satisfies Meta<typeof Radio>
export default meta
type Story = StoryObj<typeof meta>
function RadioDemo(args: Partial<ComponentProps<typeof Radio>>) {
const [value, setValue] = useState('ssd')
return (
<FieldRoot name="storageType">
<FieldsetRoot
render={(
<RadioGroup value={value} onValueChange={setValue} className="flex-col items-start gap-3" />
)}
>
<FieldsetLegend>Storage type</FieldsetLegend>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio {...args} value="ssd" />
SSD
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio {...args} value="hdd" />
HDD
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>
)
}
export const Default: Story = {
render: args => <RadioDemo {...args} />,
args: {
disabled: false,
},
}
export const Disabled: Story = {
args: {
value: 'checked',
},
render: () => (
<FieldRoot name="disabledStates">
<FieldsetRoot render={<RadioGroup value="checked" className="flex-col items-start gap-3" />}>
<FieldsetLegend>Disabled states</FieldsetLegend>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value="unchecked" disabled />
Disabled unchecked
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value="checked" disabled />
Disabled checked
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>
),
}
export const StateMatrix: Story = {
args: {
value: 'checked',
},
render: () => (
<div className="flex flex-col gap-3">
<FieldRoot name="radioStates">
<FieldsetRoot render={<RadioGroup value="checked" className="flex-col items-start gap-3" />}>
<FieldsetLegend>Radio states</FieldsetLegend>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value="unchecked" />
Unchecked
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value="checked" />
Checked
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value="disabled-unchecked" disabled />
Disabled unchecked
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value="checked" disabled />
Disabled checked
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>
<div className="flex items-center gap-2 system-sm-medium text-text-secondary">
<RadioSkeleton aria-hidden="true" />
Skeleton
</div>
</div>
),
parameters: {
docs: {
description: {
story: 'The full visual matrix for Dify radio states. State styling comes from Base UI data attributes such as data-checked and data-disabled.',
},
},
},
}

View File

@ -0,0 +1,105 @@
'use client'
import type { Radio as BaseRadioNS } from '@base-ui/react/radio'
import type { HTMLAttributes } from 'react'
import { Radio as BaseRadio } from '@base-ui/react/radio'
import { cn } from '../cn'
const radioRootClassName = cn(
'inline-flex size-4 shrink-0 touch-manipulation items-center justify-center rounded-full p-0 transition-colors motion-reduce:transition-none',
'border border-components-radio-border bg-components-radio-bg shadow-xs shadow-shadow-shadow-3',
'hover:border-components-radio-border-hover hover:bg-components-radio-bg-hover',
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-input-border-hover focus-visible:ring-offset-0',
'data-checked:border-[5px] data-checked:border-components-radio-border-checked data-checked:hover:border-components-radio-border-checked-hover',
'data-disabled:cursor-not-allowed data-disabled:border-components-radio-border-disabled data-disabled:bg-components-radio-bg-disabled',
'data-disabled:hover:border-components-radio-border-disabled data-disabled:hover:bg-components-radio-bg-disabled',
'data-disabled:data-checked:border-[5px] data-disabled:data-checked:border-components-radio-border-checked-disabled',
'data-disabled:data-checked:hover:border-components-radio-border-checked-disabled',
)
const radioIndicatorClassName = 'flex items-center justify-center data-unchecked:hidden before:size-1.5 before:rounded-full before:bg-current'
const radioControlClassName = radioRootClassName
const radioSkeletonClassName = 'size-4 shrink-0 rounded-full bg-text-quaternary opacity-20'
export type RadioRootProps<Value = string>
= Omit<BaseRadioNS.Root.Props<Value>, 'className'>
& {
className?: string
variant?: 'control' | 'unstyled'
}
export function RadioRoot<Value = string>({
className,
variant = 'control',
...props
}: RadioRootProps<Value>) {
return (
<BaseRadio.Root
className={cn(variant === 'control' && radioRootClassName, className)}
{...props}
/>
)
}
export type RadioIndicatorProps
= Omit<BaseRadioNS.Indicator.Props, 'className' | 'children'>
& {
className?: string
}
export function RadioIndicator({
className,
...props
}: RadioIndicatorProps) {
return (
<BaseRadio.Indicator
className={cn(radioIndicatorClassName, className)}
{...props}
/>
)
}
export type RadioControlProps
= Omit<RadioIndicatorProps, 'keepMounted'>
export function RadioControl({
className,
...props
}: RadioControlProps) {
return (
<BaseRadio.Indicator
keepMounted
className={cn(radioControlClassName, className)}
{...props}
/>
)
}
export type RadioProps<Value = string>
= Omit<RadioRootProps<Value>, 'children'>
export function Radio<Value = string>({
...props
}: RadioProps<Value>) {
return <RadioRoot {...props} />
}
export type RadioSkeletonProps
= Omit<HTMLAttributes<HTMLDivElement>, 'className'>
& {
className?: string
}
export function RadioSkeleton({
className,
...props
}: RadioSkeletonProps) {
return (
<div
className={cn(radioSkeletonClassName, className)}
{...props}
/>
)
}

View File

@ -167,7 +167,7 @@ The Dify community can be found on [Discord community], where you can ask questi
[Storybook]: https://storybook.js.org
[Vite+]: https://viteplus.dev
[Vitest]: https://vitest.dev
[index.spec.tsx]: ./app/components/base/radio/__tests__/index.spec.tsx
[index.spec.tsx]: ./app/components/base/action-button/__tests__/index.spec.tsx
[pnpm]: https://pnpm.io
[vinext]: https://github.com/cloudflare/vinext
[web/docs/test.md]: ./docs/test.md

View File

@ -2,11 +2,15 @@
import type { FC } from 'react'
import type { AgentConfig } from '@/models/debug'
import { cn } from '@langgenius/dify-ui/cn'
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { Radio } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { RiArrowDownSLine } from '@remixicon/react'
import * as React from 'react'
import { useState } from 'react'
@ -15,7 +19,6 @@ import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows
import { Settings04 } from '@/app/components/base/icons/src/vender/line/general'
import { CuteRobot } from '@/app/components/base/icons/src/vender/solid/communication'
import { BubbleText } from '@/app/components/base/icons/src/vender/solid/education'
import Radio from '@/app/components/base/radio/ui'
import AgentSetting from '../agent/agent-setting'
type Props = {
@ -35,26 +38,26 @@ type ItemProps = {
isChecked: boolean
description: string
Icon: any
onClick: (value: string) => void
}
const SelectItem: FC<ItemProps> = ({ text, value, Icon, isChecked, description, onClick, disabled }) => {
const SelectItem: FC<ItemProps> = ({ text, value, Icon, isChecked, description, disabled }) => {
return (
<div
className={cn(disabled ? 'opacity-50' : 'cursor-pointer', isChecked ? 'border-2 border-indigo-600 shadow-sm' : 'border border-gray-100', 'mb-2 rounded-xl bg-gray-25 p-3 pr-4 hover:bg-gray-50')}
onClick={() => !disabled && onClick(value)}
>
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="mr-3 rounded-lg bg-indigo-50 p-1">
<Icon className="size-4 text-indigo-600" />
<FieldItem>
<FieldLabel
className={cn(disabled ? 'opacity-50' : 'cursor-pointer', isChecked ? 'border-2 border-indigo-600 shadow-sm' : 'border border-gray-100', 'mb-2 rounded-xl bg-gray-25 p-3 pr-4 hover:bg-gray-50')}
>
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="mr-3 rounded-lg bg-indigo-50 p-1">
<Icon className="size-4 text-indigo-600" />
</div>
<div className="text-sm/5 font-medium text-gray-900">{text}</div>
</div>
<div className="text-sm/5 font-medium text-gray-900">{text}</div>
<Radio value={value} disabled={disabled} />
</div>
<Radio isChecked={isChecked} />
</div>
<div className="ml-9 text-xs leading-[18px] font-normal text-gray-500">{description}</div>
</div>
<div className="ml-9 text-xs leading-[18px] font-normal text-gray-500">{description}</div>
</FieldLabel>
</FieldItem>
)
}
@ -127,25 +130,38 @@ const AssistantTypePicker: FC<Props> = ({
alignOffset={-2}
popupClassName="relative left-0.5 w-[480px] rounded-xl border border-black/8 bg-white p-6 shadow-lg"
>
<div className="mb-2 text-sm/5 font-semibold text-gray-900">{t('assistantType.name', { ns: 'appDebug' })}</div>
<SelectItem
Icon={BubbleText}
value="chat"
disabled={disabled}
text={t('assistantType.chatAssistant.name', { ns: 'appDebug' })}
description={t('assistantType.chatAssistant.description', { ns: 'appDebug' })}
isChecked={!isAgent}
onClick={handleChange}
/>
<SelectItem
Icon={CuteRobot}
value="agent"
disabled={disabled}
text={t('assistantType.agentAssistant.name', { ns: 'appDebug' })}
description={t('assistantType.agentAssistant.description', { ns: 'appDebug' })}
isChecked={isAgent}
onClick={handleChange}
/>
<FieldRoot name="assistant_type" className="contents">
<FieldsetRoot
render={(
<RadioGroup
value={isAgent ? 'agent' : 'chat'}
onValueChange={handleChange}
disabled={disabled}
className="flex-col items-stretch gap-0"
/>
)}
>
<FieldsetLegend className="mb-2 py-0 text-sm/5 font-semibold text-gray-900">
{t('assistantType.name', { ns: 'appDebug' })}
</FieldsetLegend>
<SelectItem
Icon={BubbleText}
value="chat"
disabled={disabled}
text={t('assistantType.chatAssistant.name', { ns: 'appDebug' })}
description={t('assistantType.chatAssistant.description', { ns: 'appDebug' })}
isChecked={!isAgent}
/>
<SelectItem
Icon={CuteRobot}
value="agent"
disabled={disabled}
text={t('assistantType.agentAssistant.name', { ns: 'appDebug' })}
description={t('assistantType.agentAssistant.description', { ns: 'appDebug' })}
isChecked={isAgent}
/>
</FieldsetRoot>
</FieldRoot>
{!disabled && agentConfigUI}
</PopoverContent>
</Popover>

View File

@ -8,10 +8,13 @@ import type {
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { FieldRoot } from '@langgenius/dify-ui/field'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { produce } from 'immer'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Radio from '@/app/components/base/radio/ui'
import Textarea from '@/app/components/base/textarea'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
@ -145,22 +148,30 @@ const FollowUpSettingModal = ({
hideDebugWithMultipleModel
/>
</div>
<div>
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">
{t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' })}
</div>
<div className="space-y-3" role="radiogroup" aria-label={t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' }) || ''}>
<button
type="button"
role="radio"
aria-checked={promptMode === PROMPT_MODE.default}
<FieldRoot name="follow_up_prompt_mode" className="contents">
<FieldsetRoot
render={(
<RadioGroup<PromptMode>
className="flex-col items-stretch gap-3"
value={promptMode}
onValueChange={setPromptMode}
/>
)}
>
<FieldsetLegend className="mb-1.5 py-0 system-sm-semibold-uppercase text-text-secondary">
{t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' })}
</FieldsetLegend>
<RadioRoot
value={PROMPT_MODE.default}
variant="unstyled"
nativeButton
render={<button type="button" />}
className={cn(
'w-full rounded-xl border p-4 text-left transition-colors',
promptMode === PROMPT_MODE.default
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg'
: 'border-components-option-card-option-border bg-components-option-card-option-bg hover:bg-state-base-hover',
)}
onClick={() => setPromptMode(PROMPT_MODE.default)}
>
<div className="flex items-start justify-between gap-3">
<div>
@ -171,9 +182,7 @@ const FollowUpSettingModal = ({
{t('feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOptionDescription', { ns: 'appDebug' })}
</div>
</div>
<div aria-hidden="true">
<Radio isChecked={promptMode === PROMPT_MODE.default} />
</div>
<RadioControl aria-hidden="true" />
</div>
{promptMode === PROMPT_MODE.default && (
<div className="mt-3 rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-3 py-2">
@ -182,18 +191,18 @@ const FollowUpSettingModal = ({
</div>
</div>
)}
</button>
<button
type="button"
role="radio"
aria-checked={promptMode === PROMPT_MODE.custom}
</RadioRoot>
<RadioRoot
value={PROMPT_MODE.custom}
variant="unstyled"
nativeButton
render={<button type="button" />}
className={cn(
'w-full rounded-xl border p-4 text-left transition-colors',
promptMode === PROMPT_MODE.custom
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg'
: 'border-components-option-card-option-border bg-components-option-card-option-bg hover:bg-state-base-hover',
)}
onClick={() => setPromptMode(PROMPT_MODE.custom)}
>
<div className="flex items-start justify-between gap-3">
<div>
@ -204,9 +213,7 @@ const FollowUpSettingModal = ({
{t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOptionDescription', { ns: 'appDebug' })}
</div>
</div>
<div aria-hidden="true">
<Radio isChecked={promptMode === PROMPT_MODE.custom} />
</div>
<RadioControl aria-hidden="true" />
</div>
{promptMode === PROMPT_MODE.custom && (
<Textarea
@ -217,9 +224,9 @@ const FollowUpSettingModal = ({
placeholder={t('feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder', { ns: 'appDebug' }) || ''}
/>
)}
</button>
</div>
</div>
</RadioRoot>
</FieldsetRoot>
</FieldRoot>
</div>
<div className="mt-6 flex items-center justify-end gap-2">
<Button onClick={onCancel}>

View File

@ -174,6 +174,11 @@ describe('BaseField', () => {
onChange,
})
const radioGroup = screen.getByRole('radiogroup', { name: 'Visibility' })
expect(radioGroup).toHaveClass('flex')
expect(radioGroup).toHaveClass('items-center')
expect(radioGroup).not.toHaveClass('flex-col')
await act(async () => {
fireEvent.click(screen.getByText('Private'))
})
@ -449,6 +454,11 @@ describe('BaseField', () => {
expect(screen.getByText('O1')).toBeInTheDocument()
expect(screen.getByText('O2')).toBeInTheDocument()
expect(screen.getByText('O3')).toBeInTheDocument()
const radioGroup = screen.getByRole('radiogroup', { name: 'Vertical' })
expect(radioGroup).toHaveClass('flex-col')
expect(radioGroup).toHaveClass('items-stretch')
expect(radioGroup).not.toHaveClass('items-center')
})
it('should render radio UI when showRadioUI is true', () => {
@ -463,7 +473,7 @@ describe('BaseField', () => {
},
})
expect(screen.getByText('Option 1')).toBeInTheDocument()
expect(screen.getByTestId('radio-group')).toBeInTheDocument()
expect(screen.getByRole('radiogroup', { name: 'UI Radio' })).toBeInTheDocument()
})
it('should apply disabled styles', () => {

View File

@ -1,6 +1,10 @@
import type { AnyFieldApi } from '@tanstack/react-form'
import type { FieldState, FormSchema, TypeWithI18N } from '@/app/components/base/form/types'
import { cn } from '@langgenius/dify-ui/cn'
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { Radio } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import {
Select,
SelectContent,
@ -22,8 +26,6 @@ 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'
import Radio from '@/app/components/base/radio'
import RadioE from '@/app/components/base/radio/ui'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
@ -180,6 +182,8 @@ const BaseField = ({
}, [options, renderI18nObject, watchedValues])
const value = useStore(field.form.store, s => s.values[field.name])
const stringValue = typeof value === 'string' ? value : undefined
const booleanValue = typeof value === 'boolean' ? value : undefined
const { data: dynamicOptionsData, isLoading: isDynamicOptionsLoading, error: dynamicOptionsError } = useTriggerPluginDynamicOptions(
dynamicSelectParams || {
@ -388,49 +392,82 @@ const BaseField = ({
}
{
formItemType === FormTypeEnum.radio && (
<div
className={cn(
memorizedOptions.length < 3 ? 'flex items-center space-x-2' : 'space-y-2',
)}
data-testid="radio-group"
>
{
memorizedOptions.map(option => (
<div
key={option.value}
<FieldRoot name={name} className="contents">
<FieldsetRoot
render={(
<RadioGroup
value={stringValue}
onValueChange={optionValue => handleChange(optionValue)}
className={cn(
'hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 flex-1 grow cursor-pointer items-center justify-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary',
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
disabled && 'cursor-not-allowed opacity-50',
inputClassName,
memorizedOptions.length >= 3 && 'flex-col items-stretch',
)}
onClick={() => !disabled && handleChange(option.value)}
>
{
formSchema.showRadioUI && (
<RadioE
className="mr-2"
isChecked={value === option.value}
/>
)
}
{option.label}
</div>
))
}
</div>
/>
)}
>
<FieldsetLegend className="sr-only">{translatedLabel || name}</FieldsetLegend>
{
memorizedOptions.map(option => (
<FieldItem key={option.value} className={cn('min-w-0', memorizedOptions.length < 3 && 'flex-1 grow')}>
<FieldLabel
className={cn(
'hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary',
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
disabled && 'cursor-not-allowed opacity-50',
inputClassName,
)}
>
{
formSchema.showRadioUI && (
<Radio
className="mr-2"
value={option.value}
disabled={disabled}
/>
)
}
{!formSchema.showRadioUI && (
<Radio
className="sr-only"
value={option.value}
disabled={disabled}
/>
)}
{option.label}
</FieldLabel>
</FieldItem>
))
}
</FieldsetRoot>
</FieldRoot>
)
}
{
formItemType === FormTypeEnum.boolean && (
<Radio.Group
className="flex w-fit items-center"
value={value}
onChange={v => field.handleChange(v)}
>
<Radio value={true} className="mr-1!">True</Radio>
<Radio value={false}>False</Radio>
</Radio.Group>
<FieldRoot name={name} className="contents">
<FieldsetRoot
render={(
<RadioGroup<boolean>
className="w-fit gap-3"
value={booleanValue}
onValueChange={v => field.handleChange(v)}
/>
)}
>
<FieldsetLegend className="sr-only">{translatedLabel || name}</FieldsetLegend>
<FieldItem>
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
<Radio value={true} />
True
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
<Radio value={false} />
False
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>
)
}
{fieldState?.validateStatus && [FormItemValidateStatusEnum.Error, FormItemValidateStatusEnum.Warning].includes(fieldState?.validateStatus) && (

View File

@ -2,11 +2,11 @@ import type { CSSProperties } from 'react'
import type { NotionPageRow as NotionPageRowData, NotionPageSelectionMode } from './types'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import { Radio } from '@langgenius/dify-ui/radio'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import NotionIcon from '@/app/components/base/notion-icon'
import Radio from '@/app/components/base/radio/ui'
type NotionPageRowProps = {
checked: boolean
@ -58,9 +58,9 @@ const NotionPageRow = ({
: (
<Radio
className="mr-2 shrink-0"
isChecked={checked}
value={pageId}
disabled={disabled}
onCheck={() => onSelect(pageId)}
aria-label={row.page.page_name}
/>
)}
{!searchValue && row.hasChild && (

View File

@ -1,8 +1,10 @@
'use client'
import type { NotionPageRow, NotionPageSelectionMode } from './types'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import PageRow from './page-row'
type VirtualPageListProps = {
@ -32,6 +34,7 @@ const VirtualPageList = ({
selectionMode,
showPreview,
}: VirtualPageListProps) => {
const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
@ -44,6 +47,35 @@ const VirtualPageList = ({
})
const virtualRows = rowVirtualizer.getVirtualItems()
const selectedPageId = checkedIds.values().next().value
const rowNodes = virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]!
const pageId = row.page.page_id
return (
<PageRow
key={pageId}
checked={checkedIds.has(pageId)}
disabled={disabledValue.has(pageId)}
isPreviewed={previewPageId === pageId}
onPreview={onPreview}
onSelect={onSelect}
onToggle={onToggle}
row={row}
searchValue={searchValue}
selectionMode={selectionMode}
showPreview={showPreview}
style={{
height: `${virtualRow.size}px`,
left: 8,
position: 'absolute',
top: 0,
transform: `translateY(${virtualRow.start}px)`,
width: 'calc(100% - 16px)',
}}
/>
)
})
return (
<div
@ -57,34 +89,18 @@ const VirtualPageList = ({
position: 'relative',
}}
>
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]!
const pageId = row!.page.page_id
return (
<PageRow
key={pageId}
checked={checkedIds.has(pageId)}
disabled={disabledValue.has(pageId)}
isPreviewed={previewPageId === pageId}
onPreview={onPreview}
onSelect={onSelect}
onToggle={onToggle}
row={row}
searchValue={searchValue}
selectionMode={selectionMode}
showPreview={showPreview}
style={{
height: `${virtualRow.size}px`,
left: 8,
position: 'absolute',
top: 0,
transform: `translateY(${virtualRow.start}px)`,
width: 'calc(100% - 16px)',
}}
/>
)
})}
{selectionMode === 'single'
? (
<RadioGroup
aria-label={t('dataSource.notion.selector.headerTitle', { ns: 'common' })}
value={selectedPageId}
onValueChange={onSelect}
className="contents"
>
{rowNodes}
</RadioGroup>
)
: rowNodes}
</div>
</div>
)

View File

@ -1,137 +1,100 @@
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// index.spec.tsx
import { describe, expect, it, vi } from 'vitest'
import RadioCard from '../index'
describe('RadioCard', () => {
it('renders icon, title and description', () => {
render(
function renderSelectableCard({
selected = false,
onValueChange = vi.fn(),
}: {
selected?: boolean
onValueChange?: (value: string) => void
} = {}) {
render(
<RadioGroup
aria-label="Options"
value={selected ? 'card' : undefined}
onValueChange={onValueChange}
>
<RadioCard
value="card"
icon={<span data-testid="icon">ICON</span>}
title="Card Title"
description="Some description"
/>,
)
chosenConfig={<div>Config</div>}
/>
</RadioGroup>,
)
return {
radio: screen.getByRole('radio', { name: /Card Title/ }),
onValueChange,
}
}
describe('RadioCard', () => {
it('should render selectable card content and expose radio semantics', () => {
const { radio } = renderSelectableCard()
expect(screen.getByTestId('icon')).toBeInTheDocument()
expect(screen.getByText('Card Title')).toBeInTheDocument()
expect(screen.getByText('Some description')).toBeInTheDocument()
expect(radio).toHaveAttribute('aria-checked', 'false')
})
it('calls onChosen when clicked', async () => {
it('should emit RadioGroup value change when selected', async () => {
const user = userEvent.setup()
const onChosen = vi.fn()
const onValueChange = vi.fn()
const { radio } = renderSelectableCard({ onValueChange })
await user.click(radio)
expect(onValueChange).toHaveBeenCalledWith('card', expect.any(Object))
})
it('should show selected styles and configuration when checked', () => {
const { radio } = renderSelectableCard({ selected: true })
expect(radio).toHaveAttribute('aria-checked', 'true')
expect(screen.getByText('Config')).toBeInTheDocument()
expect(radio.parentElement).toHaveClass('has-[[data-checked]]:border-[1.5px]')
expect(radio.parentElement).toHaveClass('has-[[data-checked]]:bg-components-option-card-option-selected-bg')
})
it('should apply custom className to the card root and config wrapper', () => {
render(
<RadioCard
icon={<span>i</span>}
title="Clickable"
description="desc"
onChosen={onChosen}
/>,
<RadioGroup aria-label="Options" value="card">
<RadioCard
value="card"
icon={<span>i</span>}
title="Custom"
description="desc"
className="my-root-class"
chosenConfig={<div>cfg</div>}
chosenConfigWrapClassName="my-config-wrap"
/>
</RadioGroup>,
)
await user.click(screen.getByRole('button', { name: /Clickable/ }))
expect(onChosen).toHaveBeenCalledTimes(1)
const radio = screen.getByRole('radio', { name: /Custom/ })
expect(radio.parentElement).toHaveClass('my-root-class')
expect(screen.getByText('cfg').parentElement).toHaveClass('my-config-wrap')
})
it('hides radio element when noRadio is true and still shows chosen-config area (wrapper)', () => {
const { container } = render(
it('should render noRadio card as static content without radio role', () => {
render(
<RadioCard
noRadio
icon={<span>i</span>}
title="No Radio"
description="desc"
noRadio
chosenConfig={<div>Static config</div>}
/>,
)
const radioWrapper = container.querySelector('.absolute.right-3.top-3')
expect(radioWrapper).toBeNull()
// chosen-config area should appear because noRadio true triggers the block
const chosenArea = container.querySelector('.mt-2')
expect(chosenArea).toBeTruthy()
})
it('shows radio checked styles when isChosen and shows chosenConfig', () => {
const { container } = render(
<RadioCard
icon={<span>i</span>}
title="Chosen"
description="desc"
isChosen
chosenConfig={<div data-testid="chosen-config">config</div>}
/>,
)
// radio absolute wrapper exists
const radioWrapper = container.querySelector('.absolute.right-3.top-3')
expect(radioWrapper).toBeTruthy()
// inner circle div should have checked fragment in class list
const inner = radioWrapper?.querySelector('div')
expect(inner).toBeTruthy()
expect(inner?.className).toContain('border-components-radio-border-checked')
// chosenConfig rendered
expect(screen.getByTestId('chosen-config')).toBeInTheDocument()
})
it('applies custom className to root and merges chosenConfigWrapClassName', () => {
const { container } = render(
<RadioCard
icon={<span>i</span>}
title="Custom"
description="desc"
className="my-root-class"
isChosen
chosenConfig={<div>cfg</div>}
chosenConfigWrapClassName="my-config-wrap"
/>,
)
const root = container.firstChild as HTMLElement
expect(root).toBeTruthy()
expect(root.className).toContain('my-root-class')
expect(root.className).toContain('border-[1.5px]')
expect(root.className).toContain('bg-components-option-card-option-selected-bg')
const chosenWrap = container.querySelector('.mt-2 .my-config-wrap')
expect(chosenWrap).toBeTruthy()
expect(chosenWrap?.textContent).toBe('cfg')
})
it('does not render radio when noRadio true and still allows clicking on whole card', async () => {
const user = userEvent.setup()
const onChosen = vi.fn()
const { container } = render(
<RadioCard
icon={<span>i</span>}
title="ClickNoRadio"
description="desc"
noRadio
onChosen={onChosen}
/>,
)
// click title should trigger onChosen
await user.click(screen.getByRole('button', { name: /ClickNoRadio/ }))
expect(onChosen).toHaveBeenCalledTimes(1)
// radio area should be absent
expect(container.querySelector('.absolute.right-3.top-3')).toBeNull()
})
it('memo export renders correctly', () => {
render(
<RadioCard
icon={<span>i</span>}
title="Memo"
description="desc"
/>,
)
expect(screen.getByText('Memo')).toBeInTheDocument()
expect(screen.getByText('No Radio')).toBeInTheDocument()
expect(screen.getByText('Static config')).toBeInTheDocument()
expect(screen.queryByRole('radio')).not.toBeInTheDocument()
})
})

View File

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { RiCloudLine, RiCpuLine, RiDatabase2Line, RiLightbulbLine, RiRocketLine, RiShieldLine } from '@remixicon/react'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { RiDatabase2Line, RiFileList3Line, RiRocketLine } from '@remixicon/react'
import { useState } from 'react'
import RadioCard from '.'
@ -10,519 +11,89 @@ const meta = {
layout: 'centered',
docs: {
description: {
component: 'Radio card component for selecting options with rich content. Features icon, title, description, and optional configuration panel when selected.',
component: 'Radio card for rich single-choice options. Put selectable cards inside `RadioGroup`; the card passes radio props through to `RadioRoot` and uses `RadioControl` for the visual dot.',
},
},
},
tags: ['autodocs'],
argTypes: {
icon: {
description: 'Icon element to display',
},
iconBgClassName: {
control: 'text',
description: 'Background color class for icon container',
},
title: {
control: 'text',
description: 'Card title',
},
description: {
control: 'text',
description: 'Card description',
},
isChosen: {
control: 'boolean',
description: 'Whether the card is selected',
},
noRadio: {
control: 'boolean',
description: 'Hide the radio button indicator',
},
args: {
noRadio: true,
icon: <RiRocketLine className="size-5 text-purple-600" />,
title: 'Standard',
description: 'Balanced defaults for most retrieval workflows.',
},
} satisfies Meta<typeof RadioCard>
export default meta
type Story = StoryObj<typeof meta>
// Single card demo
const RadioCardDemo = (args: any) => {
const [isChosen, setIsChosen] = useState(args.isChosen || false)
return (
<div style={{ width: '400px' }}>
<RadioCard
{...args}
isChosen={isChosen}
onChosen={() => setIsChosen(!isChosen)}
/>
</div>
)
}
// Default state
export const Default: Story = {
render: args => <RadioCardDemo {...args} />,
args: {
icon: <RiRocketLine className="size-5 text-purple-600" />,
iconBgClassName: 'bg-purple-100',
title: 'Quick Start',
description: 'Get started quickly with default settings',
isChosen: false,
noRadio: false,
},
}
// Selected state
export const Selected: Story = {
render: args => <RadioCardDemo {...args} />,
args: {
icon: <RiRocketLine className="size-5 text-purple-600" />,
iconBgClassName: 'bg-purple-100',
title: 'Quick Start',
description: 'Get started quickly with default settings',
isChosen: true,
noRadio: false,
},
}
// Without radio indicator
export const NoRadio: Story = {
render: args => <RadioCardDemo {...args} />,
args: {
icon: <RiRocketLine className="size-5 text-purple-600" />,
iconBgClassName: 'bg-purple-100',
title: 'Information Card',
description: 'Card without radio indicator',
noRadio: true,
},
}
// With configuration panel
const WithConfigurationDemo = () => {
const [isChosen, setIsChosen] = useState(true)
return (
<div style={{ width: '400px' }}>
<RadioCard
icon={<RiDatabase2Line className="size-5 text-blue-600" />}
iconBgClassName="bg-blue-100"
title="Database Storage"
description="Store data in a managed database"
isChosen={isChosen}
onChosen={() => setIsChosen(!isChosen)}
chosenConfig={(
<div className="space-y-2">
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600">Region:</label>
<select className="rounded-sm border border-gray-300 px-2 py-1 text-xs">
<option>US East</option>
<option>EU West</option>
<option>Asia Pacific</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600">Size:</label>
<select className="rounded-sm border border-gray-300 px-2 py-1 text-xs">
<option>Small (10GB)</option>
<option>Medium (50GB)</option>
<option>Large (100GB)</option>
</select>
</div>
</div>
)}
/>
</div>
)
}
export const WithConfiguration: Story = {
render: () => <WithConfigurationDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Multiple cards selection
const MultipleCardsDemo = () => {
function SelectableCardsDemo() {
const [selected, setSelected] = useState('standard')
const options = [
{
value: 'standard',
icon: <RiRocketLine className="size-5 text-purple-600" />,
iconBg: 'bg-purple-100',
iconBgClassName: 'bg-purple-100',
title: 'Standard',
description: 'Perfect for most use cases',
description: 'Balanced defaults for most retrieval workflows.',
},
{
value: 'advanced',
icon: <RiCpuLine className="size-5 text-blue-600" />,
iconBg: 'bg-blue-100',
icon: <RiDatabase2Line className="size-5 text-blue-600" />,
iconBgClassName: 'bg-blue-100',
title: 'Advanced',
description: 'More features and customization',
},
{
value: 'enterprise',
icon: <RiShieldLine className="size-5 text-green-600" />,
iconBg: 'bg-green-100',
title: 'Enterprise',
description: 'Full features with premium support',
description: 'Expose extra controls when this option is selected.',
chosenConfig: (
<div className="rounded-lg bg-components-panel-bg-blur p-3 system-xs-regular text-text-tertiary">
Additional configuration appears below the selected card.
</div>
),
},
]
return (
<div style={{ width: '450px' }} className="space-y-3">
<RadioGroup
aria-label="Retrieval mode"
value={selected}
onValueChange={setSelected}
className="w-110 flex-col items-stretch gap-2"
>
{options.map(option => (
<RadioCard
key={option.value}
value={option.value}
icon={option.icon}
iconBgClassName={option.iconBg}
iconBgClassName={option.iconBgClassName}
title={option.title}
description={option.description}
isChosen={selected === option.value}
onChosen={() => setSelected(option.value)}
chosenConfig={option.chosenConfig}
/>
))}
<div className="mt-4 text-sm text-gray-600">
Selected:
{' '}
<span className="font-semibold">{selected}</span>
</div>
</div>
</RadioGroup>
)
}
export const MultipleCards: Story = {
render: () => <MultipleCardsDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Cloud provider selection
const CloudProviderSelectionDemo = () => {
const [provider, setProvider] = useState('aws')
const [region, setRegion] = useState('us-east-1')
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Select Cloud Provider</h3>
<div className="space-y-3">
<RadioCard
icon={<RiCloudLine className="size-5 text-orange-600" />}
iconBgClassName="bg-orange-100"
title="Amazon Web Services"
description="Industry-leading cloud infrastructure"
isChosen={provider === 'aws'}
onChosen={() => setProvider('aws')}
chosenConfig={(
<div className="space-y-2">
<label className="text-xs font-medium text-gray-700">Region</label>
<select
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
value={region}
onChange={e => setRegion(e.target.value)}
>
<option value="us-east-1">US East (N. Virginia)</option>
<option value="us-west-2">US West (Oregon)</option>
<option value="eu-west-1">EU (Ireland)</option>
<option value="ap-southeast-1">Asia Pacific (Singapore)</option>
</select>
</div>
)}
/>
<RadioCard
icon={<RiCloudLine className="size-5 text-blue-600" />}
iconBgClassName="bg-blue-100"
title="Microsoft Azure"
description="Enterprise-grade cloud platform"
isChosen={provider === 'azure'}
onChosen={() => setProvider('azure')}
/>
<RadioCard
icon={<RiCloudLine className="size-5 text-red-600" />}
iconBgClassName="bg-red-100"
title="Google Cloud Platform"
description="Scalable and reliable infrastructure"
isChosen={provider === 'gcp'}
onChosen={() => setProvider('gcp')}
/>
</div>
</div>
)
export const SelectableCards: Story = {
render: () => <SelectableCardsDemo />,
}
export const CloudProviderSelection: Story = {
render: () => <CloudProviderSelectionDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Deployment strategy
const DeploymentStrategyDemo = () => {
const [strategy, setStrategy] = useState('rolling')
return (
<div style={{ width: '550px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-2 text-lg font-semibold">Deployment Strategy</h3>
<p className="mb-4 text-sm text-gray-600">Choose how you want to deploy your application</p>
<div className="space-y-3">
<RadioCard
icon={<RiRocketLine className="size-5 text-green-600" />}
iconBgClassName="bg-green-100"
title="Rolling Deployment"
description="Gradually replace instances with zero downtime"
isChosen={strategy === 'rolling'}
onChosen={() => setStrategy('rolling')}
chosenConfig={(
<div className="rounded-lg bg-green-50 p-3 text-xs text-gray-700">
Recommended for production environments
<br />
Minimal risk with automatic rollback
<br />
Takes 5-10 minutes
</div>
)}
/>
<RadioCard
icon={<RiCpuLine className="size-5 text-blue-600" />}
iconBgClassName="bg-blue-100"
title="Blue-Green Deployment"
description="Switch between two identical environments"
isChosen={strategy === 'blue-green'}
onChosen={() => setStrategy('blue-green')}
chosenConfig={(
<div className="rounded-lg bg-blue-50 p-3 text-xs text-gray-700">
Instant rollback capability
<br />
Requires double the resources
<br />
Takes 2-5 minutes
</div>
)}
/>
<RadioCard
icon={<RiLightbulbLine className="size-5 text-yellow-600" />}
iconBgClassName="bg-yellow-100"
title="Canary Deployment"
description="Test with a small subset of users first"
isChosen={strategy === 'canary'}
onChosen={() => setStrategy('canary')}
chosenConfig={(
<div className="rounded-lg bg-yellow-50 p-3 text-xs text-gray-700">
Test changes with real traffic
<br />
Gradual rollout reduces risk
<br />
Takes 15-30 minutes
</div>
)}
/>
</div>
<button className="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Deploy with
{' '}
{strategy}
{' '}
strategy
</button>
</div>
)
}
export const DeploymentStrategy: Story = {
render: () => <DeploymentStrategyDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Storage options
const StorageOptionsDemo = () => {
const [storage, setStorage] = useState('ssd')
const storageOptions = [
{
value: 'ssd',
icon: <RiDatabase2Line className="size-5 text-purple-600" />,
iconBg: 'bg-purple-100',
title: 'SSD Storage',
description: 'Fast and reliable solid state drives',
price: '$0.10/GB/month',
speed: 'Up to 3000 IOPS',
},
{
value: 'hdd',
icon: <RiDatabase2Line className="size-5 text-gray-600" />,
iconBg: 'bg-gray-100',
title: 'HDD Storage',
description: 'Cost-effective magnetic disk storage',
price: '$0.05/GB/month',
speed: 'Up to 500 IOPS',
},
{
value: 'nvme',
icon: <RiDatabase2Line className="size-5 text-red-600" />,
iconBg: 'bg-red-100',
title: 'NVMe Storage',
description: 'Ultra-fast PCIe-based storage',
price: '$0.20/GB/month',
speed: 'Up to 10000 IOPS',
},
]
const selectedOption = storageOptions.find(opt => opt.value === storage)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Storage Type</h3>
<div className="space-y-3">
{storageOptions.map(option => (
<RadioCard
key={option.value}
icon={option.icon}
iconBgClassName={option.iconBg}
title={(
<div className="flex items-center justify-between">
<span>{option.title}</span>
<span className="text-xs font-normal text-gray-500">{option.price}</span>
</div>
)}
description={`${option.description} - ${option.speed}`}
isChosen={storage === option.value}
onChosen={() => setStorage(option.value)}
/>
))}
</div>
{selectedOption && (
<div className="mt-4 rounded-lg bg-gray-50 p-4">
<div className="text-sm text-gray-700">
<strong>Selected:</strong>
{' '}
{selectedOption.title}
</div>
<div className="mt-1 text-xs text-gray-500">
{selectedOption.price}
{' '}
{selectedOption.speed}
</div>
</div>
)}
</div>
)
}
export const StorageOptions: Story = {
render: () => <StorageOptionsDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - API authentication method
const APIAuthMethodDemo = () => {
const [authMethod, setAuthMethod] = useState('api_key')
const [apiKey, setApiKey] = useState('')
return (
<div style={{ width: '550px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">API Authentication</h3>
<div className="space-y-3">
<RadioCard
icon={<RiShieldLine className="size-5 text-blue-600" />}
iconBgClassName="bg-blue-100"
title="API Key"
description="Simple authentication using a secret key"
isChosen={authMethod === 'api_key'}
onChosen={() => setAuthMethod('api_key')}
chosenConfig={(
<div className="space-y-2">
<label className="text-xs font-medium text-gray-700">Your API Key</label>
<input
type="password"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="sk-..."
value={apiKey}
onChange={e => setApiKey(e.target.value)}
/>
<p className="text-xs text-gray-500">Keep your API key secure and never share it publicly</p>
</div>
)}
/>
<RadioCard
icon={<RiShieldLine className="size-5 text-green-600" />}
iconBgClassName="bg-green-100"
title="OAuth 2.0"
description="Industry-standard authorization protocol"
isChosen={authMethod === 'oauth'}
onChosen={() => setAuthMethod('oauth')}
chosenConfig={(
<div className="rounded-lg bg-green-50 p-3">
<p className="mb-2 text-xs text-gray-700">
Configure OAuth 2.0 authentication for secure access
</p>
<button className="text-xs font-medium text-green-600 hover:underline">
Configure OAuth Settings
</button>
</div>
)}
/>
<RadioCard
icon={<RiShieldLine className="size-5 text-purple-600" />}
iconBgClassName="bg-purple-100"
title="JWT Token"
description="JSON Web Token based authentication"
isChosen={authMethod === 'jwt'}
onChosen={() => setAuthMethod('jwt')}
chosenConfig={(
<div className="rounded-lg bg-purple-50 p-3 text-xs text-gray-700">
JWT tokens provide stateless authentication with expiration and refresh capabilities
</div>
)}
/>
</div>
</div>
)
}
export const APIAuthMethod: Story = {
render: () => <APIAuthMethodDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Interactive playground
const PlaygroundDemo = () => {
const [selected, setSelected] = useState('option1')
return (
<div style={{ width: '450px' }} className="space-y-3">
export const StaticInfoCard: Story = {
render: () => (
<div className="w-110">
<RadioCard
icon={<RiRocketLine className="size-5 text-purple-600" />}
iconBgClassName="bg-purple-100"
title="Option 1"
description="First option with icon and description"
isChosen={selected === 'option1'}
onChosen={() => setSelected('option1')}
/>
<RadioCard
icon={<RiDatabase2Line className="size-5 text-blue-600" />}
iconBgClassName="bg-blue-100"
title="Option 2"
description="Second option with different styling"
isChosen={selected === 'option2'}
onChosen={() => setSelected('option2')}
noRadio
icon={<RiFileList3Line className="size-5 text-indigo-600" />}
iconBgClassName="bg-indigo-100"
title="Current Retrieval Method"
description="This card summarizes the active method and is not a selectable radio option."
chosenConfig={(
<div className="rounded-sm bg-blue-50 p-2 text-xs text-gray-600">
Additional configuration appears when selected
<div className="flex gap-6 system-xs-regular text-text-tertiary">
<span>Top K: 5</span>
<span>Score: 0.8</span>
</div>
)}
/>
<RadioCard
icon={<RiCloudLine className="size-5 text-green-600" />}
iconBgClassName="bg-green-100"
title="Option 3"
description="Third option to demonstrate selection"
isChosen={selected === 'option3'}
onChosen={() => setSelected('option3')}
/>
</div>
)
),
}
export const Playground: Story = {
render: () => <PlaygroundDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story

View File

@ -1,76 +1,124 @@
'use client'
import type { FC } from 'react'
import type { RadioRootProps } from '@langgenius/dify-ui/radio'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio'
type Props = {
type BaseProps = {
className?: string
icon: React.ReactNode
icon: ReactNode
iconBgClassName?: string
title: React.ReactNode
title: ReactNode
description: string
noRadio?: boolean
isChosen?: boolean
onChosen?: () => void
chosenConfig?: React.ReactNode
chosenConfig?: ReactNode
chosenConfigWrapClassName?: string
}
const RadioCard: FC<Props> = ({
icon,
iconBgClassName = 'bg-[#F5F3FF]',
title,
description,
noRadio,
isChosen,
onChosen = noop,
chosenConfig,
chosenConfigWrapClassName,
className,
}) => {
return (
<div
className={cn(
'relative cursor-pointer rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg p-3',
isChosen && 'border-[1.5px] bg-components-option-card-option-selected-bg',
type SelectableRadioCardProps = BaseProps & {
noRadio?: false
} & Omit<RadioRootProps<string>, 'children' | 'className' | 'variant' | 'render' | 'nativeButton'>
type StaticRadioCardProps = BaseProps & {
noRadio: true
value?: never
checked?: never
}
type Props = SelectableRadioCardProps | StaticRadioCardProps
function RadioCard(props: Props) {
if (props.noRadio) {
const {
icon,
iconBgClassName = 'bg-[#F5F3FF]',
title,
description,
chosenConfig,
chosenConfigWrapClassName,
className,
} = props
return (
<div className={cn(
'relative rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg p-3',
className,
)}
>
<button
type="button"
className="flex w-full gap-x-2 border-none bg-transparent p-0 text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={onChosen}
>
<div className={cn(iconBgClassName, 'flex size-8 shrink-0 items-center justify-center rounded-lg shadow-md')}>
{icon}
<div className="flex w-full gap-x-2 text-left">
<div className={cn(iconBgClassName, 'flex size-8 shrink-0 items-center justify-center rounded-lg shadow-md')}>
{icon}
</div>
<div className="min-w-0 grow pr-8">
<div className="mb-1 system-sm-semibold text-text-secondary">{title}</div>
<div className="system-xs-regular text-text-tertiary">{description}</div>
</div>
</div>
<div className="grow">
<div className="mb-1 system-sm-semibold text-text-secondary">{title}</div>
<div className="system-xs-regular text-text-tertiary">{description}</div>
</div>
{!noRadio && (
<div className="absolute top-3 right-3">
<div
className={cn(
'size-4 rounded-full border border-components-radio-border bg-components-radio-bg shadow-xs',
isChosen && 'border-[5px] border-components-radio-border-checked',
)}
aria-hidden="true"
>
{Boolean(chosenConfig) && (
<div className="mt-2 flex gap-x-2">
<div className="size-8 shrink-0"></div>
<div className={cn(chosenConfigWrapClassName, 'grow')}>
{chosenConfig}
</div>
</div>
)}
</button>
{!!((isChosen && chosenConfig) || noRadio) && (
<div className="mt-2 flex gap-x-2">
<div className="size-8 shrink-0"></div>
<div className={cn(chosenConfigWrapClassName, 'grow')}>
{chosenConfig}
</div>
</div>
)}
</div>
)
}
const {
icon,
iconBgClassName = 'bg-[#F5F3FF]',
title,
description,
chosenConfig,
chosenConfigWrapClassName,
className,
noRadio: _noRadio,
...radioRootProps
} = props
const content = (
<>
<div className={cn(iconBgClassName, 'flex size-8 shrink-0 items-center justify-center rounded-lg shadow-md')}>
{icon}
</div>
<div className="min-w-0 grow pr-8">
<div className="mb-1 system-sm-semibold text-text-secondary">{title}</div>
<div className="system-xs-regular text-text-tertiary">{description}</div>
</div>
</>
)
const rootClassName = cn(
'group/radio-card relative rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg p-3 transition-colors',
'has-[[data-checked]]:border-[1.5px] has-[[data-checked]]:bg-components-option-card-option-selected-bg',
className,
)
const config = !!chosenConfig && (
<div className="mt-2 hidden gap-x-2 group-has-data-checked/radio-card:flex">
<div className="size-8 shrink-0"></div>
<div className={cn(chosenConfigWrapClassName, 'grow')}>
{chosenConfig}
</div>
</div>
)
return (
<div
className={rootClassName}
>
<RadioRoot
{...radioRootProps}
variant="unstyled"
nativeButton
render={<button type="button" />}
className="flex w-full cursor-pointer gap-x-2 border-none bg-transparent p-0 text-left outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-active"
>
{content}
<RadioControl className="absolute top-3 right-3" aria-hidden="true" />
</RadioRoot>
{config}
</div>
)
}
export default React.memo(RadioCard)
export default RadioCard

View File

@ -1,137 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// index.spec.tsx
import { describe, expect, it, vi } from 'vitest'
import RadioCard from '../index'
describe('RadioCard', () => {
it('renders title and description', () => {
render(
<RadioCard
title="Card Title"
description="Card Description"
isChosen={false}
onChosen={vi.fn()}
/>,
)
expect(screen.getByText('Card Title')).toBeInTheDocument()
expect(screen.getByText('Card Description')).toBeInTheDocument()
})
it('renders JSX title correctly', () => {
render(
<RadioCard
title={<span data-testid="jsx-title">JSX Title</span>}
description="Desc"
isChosen={false}
onChosen={vi.fn()}
/>,
)
expect(screen.getByTestId('jsx-title')).toBeInTheDocument()
})
it('renders icon when provided', () => {
render(
<RadioCard
title="With Icon"
description="Desc"
isChosen={false}
onChosen={vi.fn()}
icon={<span data-testid="icon">ICON</span>}
/>,
)
expect(screen.getByTestId('icon')).toBeInTheDocument()
})
it('renders extra content when provided', () => {
render(
<RadioCard
title="With Extra"
description="Desc"
isChosen={false}
onChosen={vi.fn()}
extra={<div data-testid="extra">Extra Content</div>}
/>,
)
expect(screen.getByTestId('extra')).toBeInTheDocument()
})
it('calls onChosen when clicked', async () => {
const user = userEvent.setup()
const onChosen = vi.fn()
render(
<RadioCard
title="Clickable"
description="Desc"
isChosen={false}
onChosen={onChosen}
/>,
)
await user.click(screen.getByText('Clickable'))
expect(onChosen).toHaveBeenCalledTimes(1)
})
it('applies active class when isChosen is true', () => {
const { container: inactiveContainer } = render(
<RadioCard
title="Inactive"
description="Desc"
isChosen={false}
onChosen={vi.fn()}
/>,
)
const inactiveClassName = (inactiveContainer.firstChild as HTMLElement).className
const { container: activeContainer } = render(
<RadioCard
title="Active"
description="Desc"
isChosen
onChosen={vi.fn()}
/>,
)
const activeRoot = activeContainer.firstChild as HTMLElement
expect(activeRoot.className).not.toBe(inactiveClassName)
// Since it uses CSS modules, we expect the active class to be appended or changed
// In index.tsx it's cn(s.item, isChosen && s.active)
expect(activeRoot.className.length).toBeGreaterThan(inactiveClassName.length)
expect(activeRoot.className).toContain(inactiveClassName)
})
it('does not apply active styling logic when isChosen is false', () => {
const { container } = render(
<RadioCard
title="Inactive"
description="Desc"
isChosen={false}
onChosen={vi.fn()}
/>,
)
const root = container.firstChild as HTMLElement
expect(root).toBeTruthy()
// It should have some classes but not the active one
expect(root.className).not.toBe('')
expect(root.className).not.toContain('active') // CSS modules usually append _active
})
it('memo export renders correctly', () => {
render(
<RadioCard
title="Memo"
description="Desc"
isChosen={false}
onChosen={vi.fn()}
/>,
)
expect(screen.getByText('Memo')).toBeInTheDocument()
})
})

View File

@ -1,45 +0,0 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import s from './style.module.css'
type Props = {
className?: string
title: string | React.JSX.Element | null
description: string
isChosen: boolean
onChosen: () => void
chosenConfig?: React.ReactNode
icon?: React.JSX.Element
extra?: React.ReactNode
}
const RadioCard: FC<Props> = ({
title,
description,
isChosen,
onChosen,
icon,
extra,
}) => {
return (
<div
className={cn(s.item, isChosen && s.active)}
onClick={onChosen}
>
<div className="flex px-3 py-2">
{icon}
<div>
<div className="flex items-center justify-between">
<div className="text-sm/5 font-medium text-gray-900">{title}</div>
<div className={s.radio}></div>
</div>
<div className="text-xs leading-[18px] font-normal text-gray-500">{description}</div>
</div>
</div>
{extra}
</div>
)
}
export default React.memo(RadioCard)

View File

@ -1,27 +0,0 @@
@reference "../../../../styles/globals.css";
.item {
@apply relative rounded-xl border border-gray-100 cursor-pointer;
background-color: #fcfcfd;
}
.item.active {
border-width: 1.5px;
border-color: #528BFF;
box-shadow: 0px 1px 3px rgba(16, 24, 40, 0.1), 0px 1px 2px rgba(16, 24, 40, 0.06);
}
.item:hover {
background-color: #ffffff;
border-color: #B2CCFF;
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
}
.radio {
@apply w-4 h-4 border-[2px] border-gray-200 rounded-full;
}
.item.active .radio {
border-width: 5px;
border-color: #155EEF;
}

View File

@ -1,44 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// index.spec.tsx
import { describe, expect, it, vi } from 'vitest'
import Group from '../component/group'
import Radio from '../index'
describe('Radio (index)', () => {
it('attaches Group as a property on the default export', () => {
expect(Radio.Group).toBe(Group)
})
it('renders Radio when used as a component', () => {
render(<Radio>RootLabel</Radio>)
expect(screen.getByText('RootLabel')).toBeInTheDocument()
const label = screen.getByText('RootLabel')
expect(label.tagName.toLowerCase()).toBe('label')
})
it('Radio.Group provides context to nested Radio and group onChange is called on click', async () => {
const user = userEvent.setup()
const groupOnChange = vi.fn()
render(
<Radio.Group value="val" onChange={groupOnChange}>
<Radio value="val">InnerRadio</Radio>
</Radio.Group>,
)
const root = screen.getByText('InnerRadio').closest('div') as HTMLElement
await user.click(root)
expect(groupOnChange).toHaveBeenCalledTimes(1)
expect(groupOnChange).toHaveBeenCalledWith('val')
})
it('Radio.Group can render arbitrary children', () => {
render(
<Radio.Group value={undefined} onChange={() => {}}>
<div data-testid="plain-child">child</div>
</Radio.Group>,
)
expect(screen.getByTestId('plain-child')).toBeInTheDocument()
})
})

View File

@ -1,88 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// radio-ui.spec.tsx
import { describe, expect, it, vi } from 'vitest'
import RadioUI from '../ui'
describe('RadioUI component', () => {
it('renders with correct role and aria attributes', () => {
render(<RadioUI isChecked />)
const radio = screen.getByRole('radio')
expect(radio).toBeInTheDocument()
expect(radio).toHaveAttribute('aria-checked', 'true')
expect(radio).toHaveAttribute('aria-disabled', 'false')
})
it('applies checked + enabled styles', () => {
render(<RadioUI isChecked />)
const radio = screen.getByRole('radio')
expect(radio.className).toContain('border-[5px]')
expect(radio.className).toContain('border-components-radio-border-checked')
})
it('applies unchecked + enabled styles', () => {
render(<RadioUI isChecked={false} />)
const radio = screen.getByRole('radio')
expect(radio.className).toContain('border-components-radio-border')
})
it('applies checked + disabled styles', () => {
render(<RadioUI isChecked disabled />)
const radio = screen.getByRole('radio')
expect(radio).toHaveAttribute('aria-disabled', 'true')
expect(radio.className).toContain(
'border-components-radio-border-checked-disabled',
)
})
it('applies unchecked + disabled styles', () => {
render(<RadioUI isChecked={false} disabled />)
const radio = screen.getByRole('radio')
expect(radio.className).toContain(
'border-components-radio-border-disabled',
)
expect(radio.className).toContain(
'bg-components-radio-bg-disabled',
)
})
it('calls onCheck when clicked if not disabled', async () => {
const user = userEvent.setup()
const handleCheck = vi.fn()
render(<RadioUI isChecked={false} onCheck={handleCheck} />)
const radio = screen.getByRole('radio')
await user.click(radio)
expect(handleCheck).toHaveBeenCalledTimes(1)
})
it('does not call onCheck when disabled', async () => {
const user = userEvent.setup()
const handleCheck = vi.fn()
render(
<RadioUI isChecked={false} disabled onCheck={handleCheck} />,
)
const radio = screen.getByRole('radio')
await user.click(radio)
expect(handleCheck).not.toHaveBeenCalled()
})
it('merges custom className', () => {
render(
<RadioUI isChecked={false} className="my-extra-class" />,
)
const radio = screen.getByRole('radio')
expect(radio.className).toContain('my-extra-class')
})
it('memo export renders correctly', () => {
render(<RadioUI isChecked />)
expect(screen.getByRole('radio')).toBeInTheDocument()
})
})

View File

@ -1,108 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useContextSelector } from 'use-context-selector'
// Group.test.tsx
import { describe, expect, it, vi } from 'vitest'
import RadioGroupContext from '../../../context'
import Group from '../index'
// small consumer that uses the same context as your component
function ContextConsumer({ showButton = true }: { showButton?: boolean }) {
// eslint-disable-next-line ts/no-explicit-any
const ctx = useContextSelector(RadioGroupContext, (v: any) => v)
const value = ctx?.value
const onChange = ctx?.onChange
return (
<div>
<span data-testid="radio-value">{String(value)}</span>
{showButton && (
<button
data-testid="radio-change-btn"
onClick={() => onChange?.('clicked-from-test')}
>
change
</button>
)}
</div>
)
}
describe('Group component', () => {
it('renders children and exposes provided value through context', () => {
render(
<Group value="initial-value">
<ContextConsumer />
</Group>,
)
const valueNode = screen.getByTestId('radio-value')
expect(valueNode).toBeInTheDocument()
expect(valueNode).toHaveTextContent('initial-value')
})
it('merges custom className with existing classes on root element', () => {
const { container } = render(
<Group value="v" className="my-extra-class">
<ContextConsumer />
</Group>,
)
const root = container.firstChild as HTMLElement
expect(root).toBeInTheDocument()
expect(root.className).toContain('my-extra-class')
// ensure it still has other classes (from cn + css module)
expect(root.className.length).toBeGreaterThan('my-extra-class'.length)
})
it('calls onChange from context when consumer triggers it', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
render(
<Group value="whatever" onChange={handleChange}>
<ContextConsumer />
</Group>,
)
const btn = screen.getByTestId('radio-change-btn')
await user.click(btn)
expect(handleChange).toHaveBeenCalledTimes(1)
expect(handleChange).toHaveBeenCalledWith('clicked-from-test')
})
it('does not throw if onChange is not provided and consumer calls it', async () => {
const user = userEvent.setup()
render(
<Group value={0}>
{/* the consumer will call onChange which is undefined */}
<ContextConsumer />
</Group>,
)
const btn = screen.getByTestId('radio-change-btn')
// clicking should not throw (if it threw the test would fail)
await user.click(btn)
// value still rendered correctly (verifies consumer reads numeric/false-y values too)
expect(screen.getByTestId('radio-value')).toHaveTextContent('0')
})
it('correctly passes boolean and numeric values through context', () => {
render(
<>
<Group value={false}>
<ContextConsumer />
</Group>
<Group value={123}>
<ContextConsumer showButton={false} />
</Group>
</>,
)
const nodes = screen.getAllByTestId('radio-value')
// first should be "false", second "123"
expect(nodes[0]).toHaveTextContent('false')
expect(nodes[1]).toHaveTextContent('123')
})
})

View File

@ -1,24 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import RadioGroupContext from '../../context'
import s from '../../style.module.css'
type TRadioGroupProps = {
children?: ReactNode | ReactNode[]
value?: string | number | boolean
className?: string
onChange?: (value: any) => void
}
export default function Group({ children, value, onChange, className = '' }: TRadioGroupProps): React.JSX.Element {
const onRadioChange = (value: any) => {
onChange?.(value)
}
return (
<div className={cn('flex items-center bg-workflow-block-parma-bg text-text-secondary', s.container, className)}>
<RadioGroupContext.Provider value={{ value, onChange: onRadioChange }}>
{children}
</RadioGroupContext.Provider>
</div>
)
}

View File

@ -1,95 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// index.spec.tsx
import { describe, expect, it, vi } from 'vitest'
import RadioGroupContext from '../../../context'
import Radio from '../index'
describe('Radio component', () => {
it('renders label children and assigns an id to the label', () => {
const { container } = render(<Radio>My Label</Radio>)
const label = screen.getByText('My Label')
expect(label).toBeInTheDocument()
// label must be an HTMLLabelElement with an id assigned by useId
expect(label.tagName.toLowerCase()).toBe('label')
expect(label).toHaveAttribute('id')
const root = container.firstChild as HTMLElement
expect(root).toBeTruthy()
})
it('does not render a label when children is falsey', () => {
render(<Radio />)
// there should be no <label> in the document
const labels = screen.queryAllByRole('label')
expect(labels.length).toBe(0)
// also ensure no textual children
expect(screen.queryByText(/./)).toBeNull()
})
it('calls both local onChange and group onChange when clicked', async () => {
const user = userEvent.setup()
const localChange = vi.fn()
const groupChange = vi.fn()
render(
<RadioGroupContext.Provider value={{ value: null, onChange: groupChange }}>
<Radio value="v1" onChange={localChange}>
ClickMe
</Radio>
</RadioGroupContext.Provider>,
)
const root = screen.getByText('ClickMe').closest('div') as HTMLElement
await user.click(root)
expect(localChange).toHaveBeenCalledTimes(1)
expect(localChange).toHaveBeenCalledWith('v1')
expect(groupChange).toHaveBeenCalledTimes(1)
expect(groupChange).toHaveBeenCalledWith('v1')
})
it('does not call onChange handlers when disabled', async () => {
const user = userEvent.setup()
const localChange = vi.fn()
const groupChange = vi.fn()
render(
<RadioGroupContext.Provider value={{ value: null, onChange: groupChange }}>
<Radio value="v2" onChange={localChange} disabled>
DisabledLabel
</Radio>
</RadioGroupContext.Provider>,
)
const root = screen.getByText('DisabledLabel').closest('div') as HTMLElement
await user.click(root)
expect(localChange).not.toHaveBeenCalled()
expect(groupChange).not.toHaveBeenCalled()
})
it('uses group value to determine checked state and applies checked class fragment', () => {
const { container: c1 } = render(
<RadioGroupContext.Provider value={{ value: 'yes', onChange: () => {} }}>
<Radio value="yes">CheckedByGroup</Radio>
</RadioGroupContext.Provider>,
)
const root1 = c1.firstChild as HTMLElement
expect(root1).toBeTruthy()
// component conditionally adds the 'bg-components-option-card-option-bg-hover' fragment when checked
expect(root1.className).toContain('bg-components-option-card-option-bg-hover')
const { container: c2 } = render(<Radio checked>CheckedByProp</Radio>)
const root2 = c2.firstChild as HTMLElement
expect(root2).toBeTruthy()
expect(root2.className).toContain('bg-components-option-card-option-bg-hover')
})
it('merges custom className with component classes', () => {
const { container } = render(<Radio className="my-custom-class">Label</Radio>)
const root = container.firstChild as HTMLElement
expect(root).toBeInTheDocument()
expect(root.className).toContain('my-custom-class')
// ensure other classes still exist (merged)
expect(root.className.length).toBeGreaterThan('my-custom-class'.length)
})
})

View File

@ -1,67 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useId } from 'react'
import { useContext } from 'use-context-selector'
import RadioGroupContext from '../../context'
import s from '../../style.module.css'
export type IRadioProps = {
className?: string
labelClassName?: string
children?: string | ReactNode
checked?: boolean
value?: string | number | boolean
disabled?: boolean
onChange?: (e?: IRadioProps['value']) => void
}
export default function Radio({
className = '',
labelClassName,
children = '',
checked,
value,
disabled,
onChange,
}: IRadioProps): React.JSX.Element {
const groupContext = useContext(RadioGroupContext)
const labelId = useId()
const handleChange = (e: IRadioProps['value']) => {
if (disabled)
return
onChange?.(e)
groupContext?.onChange(e)
}
const isChecked = groupContext ? groupContext.value === value : checked
const divClassName = `
flex items-center py-1 relative
px-7 cursor-pointer text-text-secondary rounded
hover:bg-components-option-card-option-bg-hover hover:shadow-xs
`
return (
<div
className={cn(
s.label,
disabled ? s.disabled : '',
isChecked ? 'bg-components-option-card-option-bg-hover shadow-xs' : '',
divClassName,
className,
)}
onClick={() => handleChange(value)}
>
{!!children && (
<label
className={
cn(labelClassName, 'cursor-pointer text-sm')
}
id={labelId}
>
{children}
</label>
)}
</div>
)
}

View File

@ -1,59 +0,0 @@
import { render, screen } from '@testing-library/react'
import { useContextSelector } from 'use-context-selector'
// context.spec.tsx
import { describe, expect, it } from 'vitest'
import RadioGroupContext from '../index'
function Consumer() {
const value = useContextSelector(RadioGroupContext, v => v)
return <div data-testid="ctx-value">{JSON.stringify(value)}</div>
}
describe('RadioGroupContext', () => {
it('provides null as default value when no provider is used', () => {
render(<Consumer />)
const node = screen.getByTestId('ctx-value')
expect(node).toBeInTheDocument()
expect(node).toHaveTextContent('null')
})
it('provides value from provider when wrapped', () => {
const providedValue = { value: 'radio', onChange: () => {} }
render(
<RadioGroupContext.Provider value={providedValue}>
<Consumer />
</RadioGroupContext.Provider>,
)
const node = screen.getByTestId('ctx-value')
expect(node).toBeInTheDocument()
expect(node).toHaveTextContent(JSON.stringify(providedValue))
})
it('updates when provider value changes', () => {
const first = { value: 'first', onChange: () => {} }
const second = { value: 'second', onChange: () => {} }
const { rerender } = render(
<RadioGroupContext.Provider value={first}>
<Consumer />
</RadioGroupContext.Provider>,
)
expect(screen.getByTestId('ctx-value')).toHaveTextContent(
JSON.stringify(first),
)
rerender(
<RadioGroupContext.Provider value={second}>
<Consumer />
</RadioGroupContext.Provider>,
)
expect(screen.getByTestId('ctx-value')).toHaveTextContent(
JSON.stringify(second),
)
})
})

View File

@ -1,6 +0,0 @@
'use client'
import { createContext } from 'use-context-selector'
const RadioGroupContext = createContext<any>(null)
export default RadioGroupContext

View File

@ -1,442 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import Radio from '.'
const meta = {
title: 'Base/Data Entry/Radio',
component: Radio,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Radio component for single selection. Usually used with Radio.Group for multiple options.',
},
},
},
tags: ['autodocs'],
argTypes: {
checked: {
control: 'boolean',
description: 'Checked state (for standalone radio)',
},
value: {
control: 'text',
description: 'Value of the radio option',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
children: {
control: 'text',
description: 'Label content',
},
},
} satisfies Meta<typeof Radio>
export default meta
type Story = StoryObj<typeof meta>
// Single radio demo
const SingleRadioDemo = (args: any) => {
const [checked, setChecked] = useState(args.checked || false)
return (
<div style={{ width: '300px' }}>
<Radio
{...args}
checked={checked}
onChange={() => setChecked(!checked)}
>
{args.children || 'Radio option'}
</Radio>
</div>
)
}
// Default single radio
export const Default: Story = {
render: args => <SingleRadioDemo {...args} />,
args: {
checked: false,
disabled: false,
children: 'Single radio option',
},
}
// Checked state
export const Checked: Story = {
render: args => <SingleRadioDemo {...args} />,
args: {
checked: true,
disabled: false,
children: 'Selected option',
},
}
// Disabled state
export const Disabled: Story = {
render: args => <SingleRadioDemo {...args} />,
args: {
checked: false,
disabled: true,
children: 'Disabled option',
},
}
// Disabled and checked
export const DisabledChecked: Story = {
render: args => <SingleRadioDemo {...args} />,
args: {
checked: true,
disabled: true,
children: 'Disabled selected option',
},
}
// Radio Group - Basic
const RadioGroupDemo = () => {
const [value, setValue] = useState('option1')
return (
<div style={{ width: '400px' }}>
<Radio.Group value={value} onChange={setValue}>
<Radio value="option1">Option 1</Radio>
<Radio value="option2">Option 2</Radio>
<Radio value="option3">Option 3</Radio>
</Radio.Group>
<div className="mt-4 text-sm text-gray-600">
Selected:
{' '}
<span className="font-semibold">{value}</span>
</div>
</div>
)
}
export const RadioGroup: Story = {
render: () => <RadioGroupDemo />,
}
// Radio Group - With descriptions
const RadioGroupWithDescriptionsDemo = () => {
const [value, setValue] = useState('basic')
return (
<div style={{ width: '500px' }}>
<h3 className="mb-3 text-sm font-medium text-gray-700">Select a plan</h3>
<Radio.Group value={value} onChange={setValue}>
<Radio value="basic">
<div>
<div className="font-medium">Basic Plan</div>
<div className="text-xs text-gray-500">Free forever - Perfect for personal use</div>
</div>
</Radio>
<Radio value="pro">
<div>
<div className="font-medium">Pro Plan</div>
<div className="text-xs text-gray-500">$19/month - Advanced features for professionals</div>
</div>
</Radio>
<Radio value="enterprise">
<div>
<div className="font-medium">Enterprise Plan</div>
<div className="text-xs text-gray-500">Custom pricing - Full features and support</div>
</div>
</Radio>
</Radio.Group>
</div>
)
}
export const RadioGroupWithDescriptions: Story = {
render: () => <RadioGroupWithDescriptionsDemo />,
}
// Radio Group - With disabled option
const RadioGroupWithDisabledDemo = () => {
const [value, setValue] = useState('available')
return (
<div style={{ width: '400px' }}>
<Radio.Group value={value} onChange={setValue}>
<Radio value="available">Available option</Radio>
<Radio value="disabled" disabled>Disabled option</Radio>
<Radio value="another">Another available option</Radio>
</Radio.Group>
</div>
)
}
export const RadioGroupWithDisabled: Story = {
render: () => <RadioGroupWithDisabledDemo />,
}
// Radio Group - Vertical layout
const VerticalLayoutDemo = () => {
const [value, setValue] = useState('email')
return (
<div style={{ width: '400px' }}>
<h3 className="mb-3 text-sm font-medium text-gray-700">Notification preferences</h3>
<Radio.Group value={value} onChange={setValue} className="flex-col gap-2">
<Radio value="email">Email notifications</Radio>
<Radio value="sms">SMS notifications</Radio>
<Radio value="push">Push notifications</Radio>
<Radio value="none">No notifications</Radio>
</Radio.Group>
</div>
)
}
export const VerticalLayout: Story = {
render: () => <VerticalLayoutDemo />,
}
// Real-world example - Settings panel
const SettingsPanelDemo = () => {
const [theme, setTheme] = useState('light')
const [language, setLanguage] = useState('en')
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-6 text-lg font-semibold">Application Settings</h3>
<div className="space-y-6">
<div>
<h4 className="mb-3 text-sm font-medium text-gray-700">Theme</h4>
<Radio.Group value={theme} onChange={setTheme} className="flex-col gap-2">
<Radio value="light">Light mode</Radio>
<Radio value="dark">Dark mode</Radio>
<Radio value="auto">Auto (system preference)</Radio>
</Radio.Group>
</div>
<div className="border-t border-gray-200 pt-6">
<h4 className="mb-3 text-sm font-medium text-gray-700">Language</h4>
<Radio.Group value={language} onChange={setLanguage} className="flex-col gap-2">
<Radio value="en">English</Radio>
<Radio value="zh"> (Chinese)</Radio>
<Radio value="es">Español (Spanish)</Radio>
<Radio value="fr">Français (French)</Radio>
</Radio.Group>
</div>
</div>
<div className="mt-6 rounded-lg bg-blue-50 p-3">
<div className="text-xs text-gray-600">
<strong>Current settings:</strong>
{' '}
Theme:
{theme}
, Language:
{language}
</div>
</div>
</div>
)
}
export const SettingsPanel: Story = {
render: () => <SettingsPanelDemo />,
}
// Real-world example - Payment method selector
const PaymentMethodSelectorDemo = () => {
const [paymentMethod, setPaymentMethod] = useState('credit_card')
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Payment Method</h3>
<Radio.Group value={paymentMethod} onChange={setPaymentMethod} className="flex-col gap-3">
<Radio value="credit_card">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">Credit Card</div>
<div className="text-xs text-gray-500">Visa, Mastercard, Amex</div>
</div>
<div className="text-xs text-gray-400">💳</div>
</div>
</Radio>
<Radio value="paypal">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">PayPal</div>
<div className="text-xs text-gray-500">Fast and secure</div>
</div>
<div className="text-xs text-gray-400">🅿</div>
</div>
</Radio>
<Radio value="bank_transfer">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">Bank Transfer</div>
<div className="text-xs text-gray-500">1-3 business days</div>
</div>
<div className="text-xs text-gray-400">🏦</div>
</div>
</Radio>
</Radio.Group>
<button className="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Continue with
{' '}
{paymentMethod.replace('_', ' ')}
</button>
</div>
)
}
export const PaymentMethodSelector: Story = {
render: () => <PaymentMethodSelectorDemo />,
}
// Real-world example - Shipping options
const ShippingOptionsDemo = () => {
const [shipping, setShipping] = useState('standard')
const shippingCosts = {
standard: 5.99,
express: 14.99,
overnight: 29.99,
}
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Shipping Method</h3>
<Radio.Group value={shipping} onChange={setShipping} className="flex-col gap-3">
<Radio value="standard">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">Standard Shipping</div>
<div className="text-xs text-gray-500">5-7 business days</div>
</div>
<div className="font-semibold text-gray-700">
$
{shippingCosts.standard}
</div>
</div>
</Radio>
<Radio value="express">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">Express Shipping</div>
<div className="text-xs text-gray-500">2-3 business days</div>
</div>
<div className="font-semibold text-gray-700">
$
{shippingCosts.express}
</div>
</div>
</Radio>
<Radio value="overnight">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">Overnight Shipping</div>
<div className="text-xs text-gray-500">Next business day</div>
</div>
<div className="font-semibold text-gray-700">
$
{shippingCosts.overnight}
</div>
</div>
</Radio>
</Radio.Group>
<div className="mt-6 border-t border-gray-200 pt-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Shipping cost:</span>
<span className="text-lg font-semibold text-gray-900">
$
{shippingCosts[shipping as keyof typeof shippingCosts]}
</span>
</div>
</div>
</div>
)
}
export const ShippingOptions: Story = {
render: () => <ShippingOptionsDemo />,
}
// Real-world example - Survey question
const SurveyQuestionDemo = () => {
const [satisfaction, setSatisfaction] = useState('')
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-2 text-base font-semibold">Customer Satisfaction Survey</h3>
<p className="mb-4 text-sm text-gray-600">How satisfied are you with our service?</p>
<Radio.Group value={satisfaction} onChange={setSatisfaction} className="flex-col gap-2">
<Radio value="very_satisfied">
<div className="flex items-center gap-2">
<span>😄</span>
<span>Very satisfied</span>
</div>
</Radio>
<Radio value="satisfied">
<div className="flex items-center gap-2">
<span>🙂</span>
<span>Satisfied</span>
</div>
</Radio>
<Radio value="neutral">
<div className="flex items-center gap-2">
<span>😐</span>
<span>Neutral</span>
</div>
</Radio>
<Radio value="dissatisfied">
<div className="flex items-center gap-2">
<span>😟</span>
<span>Dissatisfied</span>
</div>
</Radio>
<Radio value="very_dissatisfied">
<div className="flex items-center gap-2">
<span>😢</span>
<span>Very dissatisfied</span>
</div>
</Radio>
</Radio.Group>
<button
className="mt-6 w-full rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!satisfaction}
>
Submit Feedback
</button>
</div>
)
}
export const SurveyQuestion: Story = {
render: () => <SurveyQuestionDemo />,
}
// Interactive playground
const PlaygroundDemo = () => {
const [value, setValue] = useState('option1')
return (
<div style={{ width: '400px' }}>
<Radio.Group value={value} onChange={setValue}>
<Radio value="option1">Option 1</Radio>
<Radio value="option2">Option 2</Radio>
<Radio value="option3">Option 3</Radio>
<Radio value="option4" disabled>Disabled option</Radio>
</Radio.Group>
<div className="mt-4 text-sm text-gray-600">
Selected:
{' '}
<span className="font-semibold">{value}</span>
</div>
</div>
)
}
export const Playground: Story = {
render: () => <PlaygroundDemo />,
}

View File

@ -1,15 +0,0 @@
import type * as React from 'react'
import type { IRadioProps } from './component/radio'
import Group from './component/group'
import RadioComps from './component/radio'
type CompoundedComponent = {
Group: typeof Group
} & React.ForwardRefExoticComponent<IRadioProps & React.RefAttributes<HTMLElement>>
const Radio = RadioComps as CompoundedComponent
/**
* Radio
*/
Radio.Group = Group
export default Radio

View File

@ -1,13 +0,0 @@
.container {
padding: 4px;
border-radius: 4px;
}
.label {
position: relative;
margin-right: 3px;
}
.label:last-child {
margin-right: 0;
}

View File

@ -1,41 +0,0 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
type Props = {
isChecked: boolean
disabled?: boolean
onCheck?: (event: React.MouseEvent<HTMLDivElement>) => void
className?: string
}
const RadioUI: FC<Props> = ({
isChecked,
disabled = false,
onCheck,
className,
}) => {
return (
<div
role="radio"
aria-checked={isChecked}
aria-disabled={disabled}
className={cn(
'size-4 rounded-full',
isChecked && !disabled && 'border-[5px] border-components-radio-border-checked hover:border-components-radio-border-checked-hover',
!isChecked && !disabled && 'border border-components-radio-border hover:border-components-radio-border-hover',
isChecked && disabled && 'border-[5px] border-components-radio-border-checked-disabled',
!isChecked && disabled && 'border border-components-radio-border-disabled bg-components-radio-bg-disabled',
!disabled && 'bg-components-radio-bg shadow-xs shadow-shadow-shadow-3 hover:bg-components-radio-bg-hover',
className,
)}
onClick={(event) => {
if (disabled)
return
onCheck?.(event)
}}
/>
)
}
export default React.memo(RadioUI)

View File

@ -1,21 +1,8 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { RETRIEVE_METHOD } from '@/types/app'
import { retrievalIcon } from '../../../create/icons'
import RetrievalMethodInfo, { getIcon } from '../index'
// Mock RadioCard
vi.mock('@/app/components/base/radio-card', () => ({
default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => (
<div data-testid="radio-card">
<div data-testid="card-title">{title}</div>
<div data-testid="card-description">{description}</div>
<div data-testid="card-icon">{icon}</div>
<div data-testid="chosen-config">{chosenConfig}</div>
</div>
),
}))
// Mock icons
vi.mock('../../../create/icons', () => ({
retrievalIcon: {
@ -45,11 +32,9 @@ describe('RetrievalMethodInfo', () => {
it('should render correctly with full config', () => {
const { container } = render(<RetrievalMethodInfo value={defaultConfig} />)
expect(screen.getByTestId('radio-card')).toBeInTheDocument()
// Check Title & Description (mocked i18n returns key prefixed with ns)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.semantic_search.title')
expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description')
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
expect(screen.getByText('dataset.retrieval.semantic_search.description')).toBeInTheDocument()
// Check Icon
const icon = container.querySelector('img')
@ -82,7 +67,7 @@ describe('RetrievalMethodInfo', () => {
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
const { container, unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title')
expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument()
expect(container.querySelector('img')).toHaveAttribute('src', 'hybrid-icon.png')
unmount()
@ -90,7 +75,7 @@ describe('RetrievalMethodInfo', () => {
// Test FullText
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
const { container: fullTextContainer } = render(<RetrievalMethodInfo value={fullTextConfig} />)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title')
expect(screen.getByText('dataset.retrieval.full_text_search.title')).toBeInTheDocument()
expect(fullTextContainer.querySelector('img')).toHaveAttribute('src', 'fulltext-icon.png')
})

View File

@ -103,25 +103,6 @@ vi.mock('@/app/components/base/param-item/score-threshold-item', () => ({
),
}))
vi.mock('@/app/components/base/radio-card', () => ({
default: ({ isChosen, onChosen, title, description }: {
isChosen: boolean
onChosen: () => void
title: string
description: string
}) => (
<div
data-testid="radio-card"
data-chosen={isChosen}
data-title={title}
onClick={onChosen}
>
{title}
<span data-testid="radio-description">{description}</span>
</div>
),
}))
vi.mock('@langgenius/dify-ui/switch', () => ({
Switch: ({ checked, onCheckedChange }: { checked: boolean, onCheckedChange?: (v: boolean) => void }) => (
<button
@ -477,8 +458,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
const radioCards = screen.getAllByTestId('radio-card')
expect(radioCards).toHaveLength(2)
expect(screen.getAllByRole('radio')).toHaveLength(2)
})
it('should have WeightedScore option', () => {
@ -554,9 +534,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
const radioCards = screen.getAllByTestId('radio-card')
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'dataset.weightedScore.title')
fireEvent.click(weightedScoreCard!)
fireEvent.click(screen.getByRole('radio', { name: /dataset\.weightedScore\.title/ }))
expect(mockOnChange).toHaveBeenCalled()
const calledWith = mockOnChange.mock.calls[0]![0]
@ -573,9 +551,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
const radioCards = screen.getAllByTestId('radio-card')
const rerankModelCard = radioCards.find(card => card.getAttribute('data-title') === 'common.modelProvider.rerankModel.key')
fireEvent.click(rerankModelCard!)
fireEvent.click(screen.getByRole('radio', { name: /common\.modelProvider\.rerankModel\.key/ }))
expect(mockOnChange).not.toHaveBeenCalled()
})
@ -605,9 +581,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
const radioCards = screen.getAllByTestId('radio-card')
const rerankModelCard = radioCards.find(card => card.getAttribute('data-title') === 'common.modelProvider.rerankModel.key')
fireEvent.click(rerankModelCard!)
fireEvent.click(screen.getByRole('radio', { name: /common\.modelProvider\.rerankModel\.key/ }))
expect(mockNotify).toHaveBeenCalledWith('workflow.errorMsg.rerankModelRequired')
})
@ -827,9 +801,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
const radioCards = screen.getAllByTestId('radio-card')
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'dataset.weightedScore.title')
fireEvent.click(weightedScoreCard!)
fireEvent.click(screen.getByRole('radio', { name: /dataset\.weightedScore\.title/ }))
expect(mockOnChange).toHaveBeenCalled()
const calledWith = mockOnChange.mock.calls[0]![0]
@ -861,9 +833,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
const radioCards = screen.getAllByTestId('radio-card')
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'dataset.weightedScore.title')
fireEvent.click(weightedScoreCard!)
fireEvent.click(screen.getByRole('radio', { name: /dataset\.weightedScore\.title/ }))
expect(mockOnChange).toHaveBeenCalled()
const calledWith = mockOnChange.mock.calls[0]![0]

View File

@ -3,6 +3,7 @@ import type { FC } from 'react'
import type { RetrievalConfig } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
@ -207,13 +208,17 @@ const RetrievalParamConfig: FC<Props> = ({
{
isHybridSearch && (
<>
<div className="mb-4 flex gap-2">
<RadioGroup<RerankingModeEnum>
aria-label={t('modelProvider.rerankModel.key', { ns: 'common' })}
value={value.reranking_mode}
onValueChange={handleChangeRerankMode}
className="mb-4 flex gap-2"
>
{
rerankingModeOptions.map(option => (
<RadioCard
key={option.value}
isChosen={value.reranking_mode === option.value}
onChosen={() => handleChangeRerankMode(option.value)}
value={option.value}
icon={(
<img
src={
@ -230,7 +235,7 @@ const RetrievalParamConfig: FC<Props> = ({
/>
))
}
</div>
</RadioGroup>
{
value.reranking_mode === RerankingModeEnum.WeightedScore && (
<WeightedScore

View File

@ -5,6 +5,7 @@ import type { ParentChildConfig } from '../hooks'
import type { ParentMode, PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { RiSearchEyeLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
@ -115,36 +116,39 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
</div>
<Divider className="grow" bgStyle="gradient" />
</div>
<RadioCard
className="mt-1"
icon={<img src={Note.src} alt="" />}
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })}
isChosen={parentChildConfig.chunkForContext === 'paragraph'}
onChosen={() => onChunkForContextChange('paragraph')}
chosenConfig={(
<div className="flex gap-3">
<DelimiterInput
value={parentChildConfig.parent.delimiter}
tooltip={t('stepTwo.parentChildDelimiterTip', { ns: 'datasetCreation' })!}
onChange={e => onParentDelimiterChange(e.target.value)}
/>
<MaxLengthInput
unit="characters"
value={parentChildConfig.parent.maxLength}
onChange={onParentMaxLengthChange}
/>
</div>
)}
/>
<RadioCard
className="mt-2"
icon={<img src={FileList.src} alt="" />}
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })}
onChosen={() => onChunkForContextChange('full-doc')}
isChosen={parentChildConfig.chunkForContext === 'full-doc'}
/>
<RadioGroup<ParentMode>
aria-label={t('stepTwo.parentChunkForContext', { ns: 'datasetCreation' })}
value={parentChildConfig.chunkForContext}
onValueChange={value => onChunkForContextChange(value)}
className="mt-1 flex-col items-stretch gap-2"
>
<RadioCard
value="paragraph"
icon={<img src={Note.src} alt="" />}
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })}
chosenConfig={(
<div className="flex gap-3">
<DelimiterInput
value={parentChildConfig.parent.delimiter}
tooltip={t('stepTwo.parentChildDelimiterTip', { ns: 'datasetCreation' })!}
onChange={e => onParentDelimiterChange(e.target.value)}
/>
<MaxLengthInput
unit="characters"
value={parentChildConfig.parent.maxLength}
onChange={onParentMaxLengthChange}
/>
</div>
)}
/>
<RadioCard
value="full-doc"
icon={<img src={FileList.src} alt="" />}
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })}
/>
</RadioGroup>
</div>
{/* Child chunk for retrieval */}

View File

@ -1,5 +1,6 @@
import type { Mock } from 'vitest'
import type { OnlineDriveFile } from '@/models/pipeline'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { OnlineDriveFileType } from '@/models/pipeline'
@ -1378,7 +1379,11 @@ describe('Item', () => {
it('should show radio as checked when isSelected is true', () => {
const props = createItemProps({ isSelected: true, isMultipleChoice: false })
render(<ActualItem {...props} />)
render(
<RadioGroup aria-label="Files" value={props.file.id}>
<ActualItem {...props} />
</RadioGroup>,
)
const radio = getRadio()
expect(radio).toHaveAttribute('aria-checked', 'true')
})
@ -1481,7 +1486,17 @@ describe('Item', () => {
const onSelect = vi.fn()
const file = createMockOnlineDriveFile()
const props = createItemProps({ file, onSelect, isMultipleChoice: false })
render(<ActualItem {...props} />)
render(
<RadioGroup
aria-label="Files"
onValueChange={(fileId) => {
if (fileId === file.id)
onSelect(file)
}}
>
<ActualItem {...props} />
</RadioGroup>,
)
const radio = getRadio()
fireEvent.click(radio)
expect(onSelect).toHaveBeenCalledWith(file)

View File

@ -1,14 +1,9 @@
import type { OnlineDriveFile } from '@/models/pipeline'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Item from '../item'
vi.mock('@/app/components/base/radio/ui', () => ({
default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => (
<input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} />
),
}))
vi.mock('../file-icon', () => ({
default: () => <span data-testid="file-icon" />,
}))
@ -43,8 +38,13 @@ describe('Item', () => {
})
it('should render radio for file type in single choice mode', () => {
render(<Item {...defaultProps} isMultipleChoice={false} />)
expect(screen.getByTestId('radio')).toBeInTheDocument()
render(
<RadioGroup aria-label="Files" value={defaultProps.file.id}>
<Item {...defaultProps} isMultipleChoice={false} />
</RadioGroup>,
)
expect(screen.getByRole('radio', { name: 'test.pdf' })).toBeInTheDocument()
})
it('should not render checkbox for bucket type', () => {

View File

@ -1,4 +1,5 @@
import type { OnlineDriveFile } from '@/models/pipeline'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { RiLoader2Line } from '@remixicon/react'
import * as React from 'react'
import { useEffect, useRef } from 'react'
@ -53,6 +54,25 @@ const List = ({
const isPartialLoading = isLoading && fileList.length > 0
const isEmptyFolder = !isLoading && fileList.length === 0 && keywords.length === 0
const isSearchResultEmpty = !isLoading && fileList.length === 0 && keywords.length > 0
const selectedFileId = selectedFileIds[0]
const handleRadioChange = (fileId: string) => {
const selectedFile = fileList.find(file => file.id === fileId)
if (selectedFile)
handleSelectFile(selectedFile)
}
const fileItems = fileList.map((file) => {
const isSelected = selectedFileIds.includes(file.id)
return (
<Item
key={file.id}
file={file}
isSelected={isSelected}
onSelect={handleSelectFile}
onOpen={handleOpenFolder}
isMultipleChoice={supportBatchUpload}
/>
)
})
return (
<div className="grow overflow-hidden p-1 pt-0">
@ -73,21 +93,18 @@ const List = ({
}
{fileList.length > 0 && (
<div className="flex h-full flex-col gap-y-px overflow-y-auto rounded-[10px] bg-background-section px-1 py-1.5">
{
fileList.map((file) => {
const isSelected = selectedFileIds.includes(file.id)
return (
<Item
key={file.id}
file={file}
isSelected={isSelected}
onSelect={handleSelectFile}
onOpen={handleOpenFolder}
isMultipleChoice={supportBatchUpload}
/>
)
})
}
{supportBatchUpload
? fileItems
: (
<RadioGroup
aria-label={t('onlineDrive.breadcrumbs.allFiles', { ns: 'datasetPipeline' })}
value={selectedFileId}
onValueChange={handleRadioChange}
className="contents"
>
{fileItems}
</RadioGroup>
)}
{
isPartialLoading && (
<div

View File

@ -1,10 +1,10 @@
import type { OnlineDriveFile } from '@/models/pipeline'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Radio } from '@langgenius/dify-ui/radio'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Radio from '@/app/components/base/radio/ui'
import { formatFileSize } from '@/utils/format'
import FileIcon from './file-icon'
@ -33,11 +33,6 @@ const Item = ({
const disabledTip = t('onlineDrive.notSupportedFileType', { ns: 'datasetPipeline' })
const handleSelect = useCallback((e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => {
e.stopPropagation()
onSelect(file)
}, [file, onSelect])
const handleCheckboxSelect = useCallback(() => {
onSelect(file)
}, [file, onSelect])
@ -70,12 +65,14 @@ const Item = ({
</span>
)}
{!isBucket && !isMultipleChoice && (
<Radio
className="shrink-0"
disabled={disabled}
isChecked={isSelected}
onCheck={handleSelect}
/>
<span onClick={event => event.stopPropagation()}>
<Radio
className="shrink-0"
disabled={disabled}
value={file.id}
aria-label={name}
/>
</span>
)}
{disabled
? (

View File

@ -1,4 +1,5 @@
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CrawledResultItem from '../crawled-result-item'
@ -9,12 +10,6 @@ vi.mock('@langgenius/dify-ui/button', () => ({
),
}))
vi.mock('@/app/components/base/radio/ui', () => ({
default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => (
<input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} />
),
}))
describe('CrawledResultItem', () => {
const defaultProps = {
payload: {
@ -47,8 +42,13 @@ describe('CrawledResultItem', () => {
})
it('should render radio in single choice mode', () => {
render(<CrawledResultItem {...defaultProps} isMultipleChoice={false} />)
expect(screen.getByTestId('radio')).toBeInTheDocument()
render(
<RadioGroup aria-label="Crawled pages" value={defaultProps.payload.source_url}>
<CrawledResultItem {...defaultProps} isMultipleChoice={false} />
</RadioGroup>,
)
expect(screen.getByRole('radio', { name: /Test Page/ })).toBeInTheDocument()
})
it('should show preview button when showPreview is true', () => {

View File

@ -1,4 +1,5 @@
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
@ -260,18 +261,24 @@ describe('CrawledResultItem', () => {
it('should toggle radio state when isMultipleChoice is false', () => {
const mockOnCheckChange = vi.fn()
const { container } = render(
<CrawledResultItem
{...defaultProps}
isMultipleChoice={false}
isChecked={false}
onCheckChange={mockOnCheckChange}
/>,
render(
<RadioGroup
aria-label="Crawled pages"
onValueChange={(sourceUrl) => {
if (sourceUrl === defaultProps.payload.source_url)
mockOnCheckChange(true)
}}
>
<CrawledResultItem
{...defaultProps}
isMultipleChoice={false}
isChecked={false}
onCheckChange={mockOnCheckChange}
/>
</RadioGroup>,
)
// Act - Radio uses size-4 rounded-full classes
const radio = container.querySelector('.size-4.rounded-full')!
fireEvent.click(radio)
fireEvent.click(screen.getByRole('radio', { name: /Test Page Title/ }))
expect(mockOnCheckChange).toHaveBeenCalledWith(true)
})

View File

@ -3,10 +3,9 @@ import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import { Radio } from '@langgenius/dify-ui/radio'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Radio from '@/app/components/base/radio/ui'
type CrawledResultItemProps = {
payload: CrawlResultItemType
@ -29,10 +28,6 @@ const CrawledResultItem = ({
}: CrawledResultItemProps) => {
const { t } = useTranslation()
const handleCheckChange = useCallback(() => {
onCheckChange(!isChecked)
}, [isChecked, onCheckChange])
return (
<div className={cn(
'relative flex gap-x-2 rounded-lg p-2',
@ -65,11 +60,10 @@ const CrawledResultItem = ({
</label>
)
: (
<>
<label className="flex min-w-0 grow cursor-pointer gap-x-2">
<Radio
className="shrink-0"
isChecked={isChecked}
onCheck={handleCheckChange}
value={payload.source_url}
/>
<div className="flex min-w-0 grow flex-col gap-y-0.5">
<div
@ -85,7 +79,7 @@ const CrawledResultItem = ({
{payload.source_url}
</div>
</div>
</>
</label>
)
}
{showPreview && (

View File

@ -1,6 +1,7 @@
'use client'
import type { CrawlResultItem } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@ -62,6 +63,26 @@ const CrawledResult = ({
onPreview(list[index]!, index)
}, [list, onPreview])
const selectedSourceUrl = checkedList[0]?.source_url
const handleRadioChange = useCallback((sourceUrl: string) => {
const selectedItem = list.find(item => item.source_url === sourceUrl)
if (selectedItem)
onSelectedChange([selectedItem])
}, [list, onSelectedChange])
const resultItems = list.map((item, index) => (
<CrawledResultItem
key={item.source_url}
payload={item}
isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)}
onCheckChange={handleItemCheckChange(item)}
isPreview={index === previewIndex}
onPreview={handlePreview.bind(null, index)}
showPreview={showPreview}
isMultipleChoice={isMultipleChoice}
/>
))
return (
<div className={cn('flex flex-col gap-y-2', className)}>
<div className="pt-2 system-sm-medium text-text-primary">
@ -81,20 +102,26 @@ const CrawledResult = ({
/>
</div>
)}
<div className="flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2">
{list.map((item, index) => (
<CrawledResultItem
key={item.source_url}
payload={item}
isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)}
onCheckChange={handleItemCheckChange(item)}
isPreview={index === previewIndex}
onPreview={handlePreview.bind(null, index)}
showPreview={showPreview}
isMultipleChoice={isMultipleChoice}
/>
))}
</div>
{isMultipleChoice
? (
<div className="flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2">
{resultItems}
</div>
)
: (
<RadioGroup
aria-label={t(`${I18N_PREFIX}.scrapTimeInfo`, {
ns: 'datasetCreation',
total: list.length,
time: usedTime.toFixed(1),
})}
value={selectedSourceUrl}
onValueChange={handleRadioChange}
className="flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2"
>
{resultItems}
</RadioGroup>
)}
</div>
</div>
)

View File

@ -47,12 +47,9 @@ describe('DocTypeSelector', () => {
})
it('should render icon buttons for each doc type', () => {
const { container } = render(<DocTypeSelector {...defaultProps} />)
render(<DocTypeSelector {...defaultProps} />)
// Each doc type renders an IconButton wrapped in Radio
const iconButtons = container.querySelectorAll('button[type="button"]')
// 3 doc types + 1 confirm button = 4 buttons
expect(iconButtons.length).toBeGreaterThanOrEqual(3)
expect(screen.getAllByRole('radio')).toHaveLength(3)
})
it('should render confirm button disabled when tempDocType is empty', () => {

View File

@ -3,9 +3,12 @@ import type { FC } from 'react'
import type { DocType } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { Radio } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import Radio from '@/app/components/base/radio'
import { useMetadataMap } from '@/hooks/use-metadata'
import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets'
import s from '../style.module.css'
@ -20,12 +23,12 @@ const IconButton: FC<{ type: DocType, isChecked: boolean }> = ({ type, isChecked
<Tooltip>
<TooltipTrigger
render={(
<button type="button" className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
<span className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
<TypeIcon
iconName={metadataMap[type].iconName || ''}
className={`group-hover:bg-primary-600 ${isChecked ? 'bg-primary-600!' : ''}`}
/>
</button>
</span>
)}
/>
<TooltipContent>
@ -53,6 +56,7 @@ const DocTypeSelector: FC<DocTypeSelectorProps> = ({
onCancel,
}) => {
const { t } = useTranslation()
const metadataMap = useMetadataMap()
const isFirstTime = !docType && !documentType
const currValue = tempDocType ?? documentType
@ -62,22 +66,44 @@ const DocTypeSelector: FC<DocTypeSelectorProps> = ({
<div className={s.desc}>{t('metadata.desc', { ns: 'datasetDocuments' })}</div>
)}
<div className={s.operationWrapper}>
{isFirstTime && (
<span className={s.title}>{t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })}</span>
)}
{documentType && (
<>
<span className={s.title}>{t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })}</span>
<span className={s.changeTip}>{t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })}</span>
</>
)}
<Radio.Group value={currValue ?? ''} onChange={onTempDocTypeChange} className={s.radioGroup}>
{CUSTOMIZABLE_DOC_TYPES.map(type => (
<Radio key={type} value={type} className={`${s.radio} ${currValue === type ? 'shadow-none' : ''}`}>
<IconButton type={type} isChecked={currValue === type} />
</Radio>
))}
</Radio.Group>
<FieldRoot name="document_type" className="contents">
<FieldsetRoot
render={(
<RadioGroup
value={currValue ?? ''}
onValueChange={onTempDocTypeChange}
className={s.radioGroup}
/>
)}
>
<FieldsetLegend className={s.title}>
{isFirstTime
? t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })
: t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })}
</FieldsetLegend>
{documentType && (
<span className={s.changeTip}>{t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })}</span>
)}
{CUSTOMIZABLE_DOC_TYPES.map(type => (
<FieldItem key={type}>
<FieldLabel
className={cn(
s.radio,
'focus-within:ring-2 focus-within:ring-components-input-border-hover focus-within:ring-offset-1 focus-within:outline-hidden',
currValue === type && 'shadow-none',
)}
>
<Radio
value={type}
aria-label={metadataMap[type].text}
className="sr-only"
/>
<IconButton type={type} isChecked={currValue === type} />
</FieldLabel>
</FieldItem>
))}
</FieldsetRoot>
</FieldRoot>
{isFirstTime && (
<Button variant="primary" onClick={onConfirm} disabled={!tempDocType}>
{t('metadata.firstMetaAction', { ns: 'datasetDocuments' })}

View File

@ -13,11 +13,13 @@ import type {
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { Radio } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useCallback, useState } from 'react'
import { Infotip } from '@/app/components/base/infotip'
import Radio from '@/app/components/base/radio'
import RadioE from '@/app/components/base/radio/ui'
import { AppSelector } from '@/app/components/plugins/plugin-detail-panel/app-selector'
import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector'
import MultipleToolSelector from '@/app/components/plugins/plugin-detail-panel/multiple-tool-selector'
@ -218,41 +220,53 @@ function Form<
const disabled = isEditMode && (variable === '__model_type' || variable === '__model_name')
const gridColumnsClassName = radioGridColumnsClassNames[options.length] ?? 'grid-cols-1'
const selectedValue = typeof value[variable] === 'string' ? value[variable] : undefined
const translatedLabel = label[language] || label.en_US
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
<FieldRoot key={variable} name={variable} className="contents">
<FieldsetRoot
render={(
<RadioGroup
value={selectedValue}
onValueChange={val => handleFormChange(variable, val)}
className={cn(itemClassName, 'grid gap-3 py-3', gridColumnsClassName)}
/>
)}
{infotipContent}
</div>
<div className={cn('grid gap-3', gridColumnsClassName)}>
>
<FieldsetLegend className={cn(fieldLabelClassName, 'col-span-full flex items-center py-2 system-sm-semibold text-text-secondary')}>
<span>{translatedLabel}</span>
{required && (
<span className="ml-1 text-red-500">*</span>
)}
{infotipContent}
</FieldsetLegend>
{options.filter((option) => {
if (option.show_on.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map(option => (
<div
className={`
<FieldItem key={`${variable}-${option.value}`} className="min-w-0">
<FieldLabel
className={`
flex cursor-pointer items-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg px-3 py-2
${value[variable] === option.value && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm'}
${disabled && 'cursor-not-allowed! opacity-60'}
`}
onClick={() => handleFormChange(variable, option.value)}
key={`${variable}-${option.value}`}
>
<RadioE isChecked={value[variable] === option.value} />
>
<Radio value={option.value} disabled={disabled} />
<div className="system-sm-regular text-text-secondary">{option.label[language] || option.label.en_US}</div>
</div>
<div className="system-sm-regular text-text-secondary">{option.label[language] || option.label.en_US}</div>
</FieldLabel>
</FieldItem>
))}
</div>
{fieldMoreInfo?.(formSchema)}
{validating && changeKey === variable && <ValidatingTip />}
</div>
<div className="col-span-full">
{fieldMoreInfo?.(formSchema)}
{validating && changeKey === variable && <ValidatingTip />}
</div>
</FieldsetRoot>
</FieldRoot>
)
}
@ -327,26 +341,44 @@ function Form<
if (show_on.length && !show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value))
return null
const booleanValue = typeof value[variable] === 'boolean' ? value[variable] : undefined
const translatedLabel = label[language] || label.en_US
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className="flex items-center justify-between py-2 system-sm-semibold text-text-secondary">
<div className="flex items-center space-x-2">
<span className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}>{label[language] || label.en_US}</span>
{required && (
<span className="ml-1 text-red-500">*</span>
<FieldRoot name={variable} className="contents">
<FieldsetRoot
render={(
<RadioGroup<boolean>
className="flex items-center justify-between gap-3 py-2"
value={booleanValue}
onValueChange={val => handleFormChange(variable, val)}
/>
)}
{infotipContent}
</div>
<Radio.Group
className="flex items-center"
value={value[variable]}
onChange={val => handleFormChange(variable, val)}
>
<Radio value={true} className="mr-1!">True</Radio>
<Radio value={false}>False</Radio>
</Radio.Group>
</div>
<FieldsetLegend className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}>
<span>{translatedLabel}</span>
{required && (
<span className="ml-1 text-red-500">*</span>
)}
{infotipContent}
</FieldsetLegend>
<div className="flex items-center gap-3">
<FieldItem>
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
<Radio value={true} />
True
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
<Radio value={false} />
False
</FieldLabel>
</FieldItem>
</div>
</FieldsetRoot>
</FieldRoot>
{fieldMoreInfo?.(formSchema)}
</div>
)

View File

@ -4,6 +4,10 @@ import type {
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { Radio } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select'
import { Slider } from '@langgenius/dify-ui/slider'
import { Switch } from '@langgenius/dify-ui/switch'
@ -11,7 +15,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import PromptEditor from '@/app/components/base/prompt-editor'
import Radio from '@/app/components/base/radio'
import TagInput from '@/app/components/base/tag-input'
import { BlockEnum } from '@/app/components/workflow/types'
import { useLanguage } from '../hooks'
@ -218,15 +221,35 @@ function ParameterItem({
}
if (parameterRule.type === 'boolean') {
const booleanValue = typeof renderValue === 'boolean' ? renderValue : undefined
const translatedLabel = parameterRule.label[language] || parameterRule.label.en_US
return (
<Radio.Group
className="flex w-[150px] items-center"
value={renderValue as boolean}
onChange={handleRadioChange}
>
<Radio value={true} className="w-[70px] px-[18px]">True</Radio>
<Radio value={false} className="w-[70px] px-[18px]">False</Radio>
</Radio.Group>
<FieldRoot name={parameterRule.name} className="contents">
<FieldsetRoot
render={(
<RadioGroup<boolean>
className="w-[150px] gap-3"
value={booleanValue}
onValueChange={handleRadioChange}
/>
)}
>
<FieldsetLegend className="sr-only">{translatedLabel}</FieldsetLegend>
<FieldItem>
<FieldLabel className="flex w-[70px] items-center gap-1.5 system-sm-regular text-text-secondary">
<Radio value={true} />
True
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex w-[70px] items-center gap-1.5 system-sm-regular text-text-secondary">
<Radio value={false} />
False
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>
)
}

View File

@ -12,11 +12,14 @@ import {
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { Radio } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import Radio from '@/app/components/base/radio/ui'
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
type Props = {
@ -30,22 +33,21 @@ type ItemProps = {
text: string
value: AuthType | AuthHeaderPrefix
isChecked: boolean
onClick: (value: AuthType | AuthHeaderPrefix) => void
}
function SelectItem({ text, value, isChecked, onClick }: ItemProps) {
function SelectItem({ text, value, isChecked }: ItemProps) {
return (
<button
type="button"
className={cn(
isChecked ? 'border-2 border-util-colors-indigo-indigo-600 bg-components-panel-on-panel-item-bg shadow-sm' : 'border border-components-card-border',
'mb-2 flex h-9 w-37.5 cursor-pointer items-center space-x-2 rounded-xl bg-components-panel-on-panel-item-bg pl-3 text-left outline-hidden hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover',
)}
onClick={() => onClick(value)}
>
<Radio isChecked={isChecked} />
<div className="system-sm-regular text-text-primary">{text}</div>
</button>
<FieldItem>
<FieldLabel
className={cn(
isChecked ? 'border-2 border-util-colors-indigo-indigo-600 bg-components-panel-on-panel-item-bg shadow-sm' : 'border border-components-card-border',
'mb-2 flex h-9 w-37.5 cursor-pointer items-center space-x-2 rounded-xl bg-components-panel-on-panel-item-bg pl-3 text-left outline-hidden hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover',
)}
>
<Radio value={value} />
<div className="system-sm-regular text-text-primary">{text}</div>
</FieldLabel>
</FieldItem>
)
}
@ -57,6 +59,28 @@ export default function ConfigCredential({
}: Props) {
const { t } = useTranslation()
const [tempCredential, setTempCredential] = useState<Credential>(credential)
const handleAuthTypeChange = (value: AuthType) => {
if (value === AuthType.none) {
setTempCredential({ auth_type: value })
return
}
if (value === AuthType.apiKeyHeader) {
setTempCredential({
auth_type: value,
api_key_header: tempCredential.api_key_header || 'Authorization',
api_key_value: tempCredential.api_key_value || '',
api_key_header_prefix: tempCredential.api_key_header_prefix || AuthHeaderPrefix.custom,
})
return
}
setTempCredential({
auth_type: value,
api_key_query_param: tempCredential.api_key_query_param || 'key',
api_key_value: tempCredential.api_key_value || '',
})
}
return (
<Drawer
@ -94,65 +118,68 @@ export default function ConfigCredential({
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 pt-2">
<div className="space-y-4">
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.type', { ns: 'tools' })}</div>
<div className="flex space-x-3">
<FieldRoot name="auth_type" className="contents">
<FieldsetRoot
render={(
<RadioGroup<AuthType>
className="space-x-3"
value={tempCredential.auth_type}
onValueChange={handleAuthTypeChange}
/>
)}
>
<FieldsetLegend className="py-2 system-sm-medium text-text-primary">
{t('createTool.authMethod.type', { ns: 'tools' })}
</FieldsetLegend>
<SelectItem
text={t('createTool.authMethod.types.none', { ns: 'tools' })}
value={AuthType.none}
isChecked={tempCredential.auth_type === AuthType.none}
onClick={value => setTempCredential({
auth_type: value as AuthType,
})}
/>
<SelectItem
text={t('createTool.authMethod.types.api_key_header', { ns: 'tools' })}
value={AuthType.apiKeyHeader}
isChecked={tempCredential.auth_type === AuthType.apiKeyHeader}
onClick={value => setTempCredential({
auth_type: value as AuthType,
api_key_header: tempCredential.api_key_header || 'Authorization',
api_key_value: tempCredential.api_key_value || '',
api_key_header_prefix: tempCredential.api_key_header_prefix || AuthHeaderPrefix.custom,
})}
/>
<SelectItem
text={t('createTool.authMethod.types.api_key_query', { ns: 'tools' })}
value={AuthType.apiKeyQuery}
isChecked={tempCredential.auth_type === AuthType.apiKeyQuery}
onClick={value => setTempCredential({
auth_type: value as AuthType,
api_key_query_param: tempCredential.api_key_query_param || 'key',
api_key_value: tempCredential.api_key_value || '',
})}
/>
</div>
</div>
</FieldsetRoot>
</FieldRoot>
{tempCredential.auth_type === AuthType.apiKeyHeader && (
<>
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authHeaderPrefix.title', { ns: 'tools' })}</div>
<div className="flex space-x-3">
<FieldRoot name="api_key_header_prefix" className="contents">
<FieldsetRoot
render={(
<RadioGroup<AuthHeaderPrefix>
className="space-x-3"
value={tempCredential.api_key_header_prefix}
onValueChange={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value })}
/>
)}
>
<FieldsetLegend className="py-2 system-sm-medium text-text-primary">
{t('createTool.authHeaderPrefix.title', { ns: 'tools' })}
</FieldsetLegend>
<SelectItem
text={t('createTool.authHeaderPrefix.types.basic', { ns: 'tools' })}
value={AuthHeaderPrefix.basic}
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.basic}
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
/>
<SelectItem
text={t('createTool.authHeaderPrefix.types.bearer', { ns: 'tools' })}
value={AuthHeaderPrefix.bearer}
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.bearer}
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
/>
<SelectItem
text={t('createTool.authHeaderPrefix.types.custom', { ns: 'tools' })}
value={AuthHeaderPrefix.custom}
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.custom}
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
/>
</div>
</div>
</FieldsetRoot>
</FieldRoot>
<div>
<div className="flex items-center py-2 system-sm-medium text-text-primary">
{t('createTool.authMethod.key', { ns: 'tools' })}

View File

@ -547,4 +547,4 @@ Test examples in the project:
[Testing Library Best Practices]: https://kentcdodds.com/blog/common-mistakes-with-react-testing-library
[Vitest Documentation]: https://vitest.dev/guide
[Vitest Mocking Guide]: https://vitest.dev/guide/mocking.html
[index.spec.tsx]: ../app/components/base/radio/__tests__/index.spec.tsx
[index.spec.tsx]: ../app/components/base/action-button/__tests__/index.spec.tsx

View File

@ -374,13 +374,13 @@ Options:
Examples:
# Analyze a component and generate test prompt
pnpm analyze-component app/components/base/radio/index.tsx
pnpm analyze-component app/components/base/action-button/index.tsx
# Output as JSON
pnpm analyze-component app/components/base/radio/index.tsx --json
pnpm analyze-component app/components/base/action-button/index.tsx --json
# Review existing test
pnpm analyze-component app/components/base/radio/index.tsx --review
pnpm analyze-component app/components/base/action-button/index.tsx --review
For complete testing guidelines, see: web/docs/test.md
`)