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
52 changed files with 1605 additions and 2282 deletions
+8
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"
@@ -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')
})
})
@@ -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.',
},
},
},
}
@@ -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}
/>
)
}
@@ -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')
})
})
@@ -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.',
},
},
},
}
+105
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}
/>
)
}