mirror of
https://github.com/langgenius/dify.git
synced 2026-06-03 08:16:37 +08:00
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:
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user