fix(dify-ui): preserve radio value generics (#38429)

This commit is contained in:
yyh 2026-07-05 22:01:36 +08:00 committed by GitHub
parent e742e0ec17
commit 6dff7c0a85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 91 additions and 33 deletions

View File

@ -40,9 +40,13 @@ Flag:
- JavaScript conditional class logic for visual states that the Dify UI/Base UI primitive already exposes through `data-*` attributes or CSS variables.
- Controlled props added when uncontrolled DOM state or CSS variables would be enough.
- Thin wrappers that rename Base UI parts without adding semantics.
- Generic Base UI selection primitives wrapped without preserving their value generics, such as `Select.Root<Value, Multiple>`, `RadioGroup<Value>`, or `Radio.Root<Value>`.
- Shared select/radio option components that type selected values as `string` while callers pass enums, unions, booleans, numbers, objects, or nullable placeholder values.
Prefer Base UI/Dify UI data attributes and CSS variables for visual state: `data-open`, `data-checked`, `data-disabled`, `data-highlighted`, `data-popup-open`, `group-data-*`, `peer-data-*`, `has-[:focus-visible]`, and primitive CSS variables such as anchor width or transform origin. Use JS conditional classes for product/business state that the primitive does not expose.
For non-string `Select` and `RadioGroup` values, prefer explicit domain generics at the root and at child value carriers. JSX children do not inherit the parent generic, so `RadioGroup<PromptMode>` should compose with `Radio<PromptMode>`, `RadioRoot<PromptMode>`, or option values from a typed collection. For `Select`, prefer the Base UI `items` collection pattern for typed value-to-label rendering, and flag string coercion helpers used only to recover display labels.
## Forms
Flag:

View File

@ -69,6 +69,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Avoid barrel files that only re-export secondary owners. `index.tsx` is acceptable for a route/tab entry component; import header controls, switches, sections, and row owners from their concrete owner files.
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
- Preserve domain value types for selection components. Do not widen enum, union, boolean, numeric, object, or nullable select/radio values to `string`; keep wrappers and option value carriers typed from their feature option collection.
- Avoid `common.tsx` buckets for shared UI. Use a feature-local `components/` folder with concrete filenames that describe the shared role.
- Do not create type aliases that only rename another type. Use aliases only for real UI concepts, refinements, or reusable local contracts.
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.

View File

@ -10,6 +10,8 @@ Shared design tokens, the `cn()` utility, CSS-first Tailwind styles, and headles
- One component per folder: `src/<name>/index.tsx`, optional `index.stories.tsx` and `__tests__/index.spec.tsx`. Add a matching `./<name>` subpath to `package.json#exports`.
- Props pattern: `Omit<BaseXxx.Root.Props, 'className' | ...> & VariantProps<typeof xxxVariants> & { /* custom */ }`.
- Use plain `Omit<...>` only for non-union Base UI props. When a prop changes the valid shape of related props (for example `value` / `defaultValue`, `multiple` / `value`, or `clearable` / `onChange`), model that relationship with an explicit discriminated union or a distributive helper instead of flattening the props.
- Preserve Base UI generic value contracts in wrappers. If the upstream primitive is generic, expose the same generic parameters and pass them through to the Base UI part, such as `Select.Root<Value, Multiple>`, `RadioGroup<Value>`, or `Radio.Root<Value>`.
- Do not hard-code selection wrappers to `string` unless the upstream primitive is string-only. Select and radio wrappers that carry selected values should preserve the primitive's generic value contract.
- When a component accepts a prop typed from a shared internal module, `export type` it from that component so consumers import it from the component subpath.
- Prefer Base UI data attributes and CSS variables for visual states; do not mirror state in React solely to add classes.
- When a Base UI API or selector contract is unclear, read the docs linked from `README.md` and the local `@base-ui/react` `.d.ts` files before coding.

View File

@ -101,6 +101,23 @@ Use `FieldsetRoot` and `FieldsetLegend` when one field is represented by a group
`FieldsetRoot` provides the group semantics and legend relationship. It does not own the interactive state of the grouped control. Pass `disabled`, `value`, `defaultValue`, and change handlers to the actual group primitive (`CheckboxGroup`, radio group, slider root, etc.) instead of relying on the fieldset wrapper to manage them.
## Typed value contracts
Selection primitives should preserve the caller's domain value type instead of widening values to `string`. Use `Select<Value, Multiple>`, `RadioGroup<Value>`, `Radio<Value>`, and `RadioRoot<Value>` when the selected value is an enum, union, boolean, number, object, or nullable placeholder value.
Root-level generics type `value`, `defaultValue`, `onValueChange`, and collection props such as `items`, but JSX children do not automatically inherit the parent generic. For non-string radio groups, type the child item too:
```tsx
<RadioGroup<PromptMode> value={promptMode} onValueChange={setPromptMode}>
<RadioRoot<PromptMode> value={PROMPT_MODE.default} />
<RadioRoot<PromptMode> value={PROMPT_MODE.custom} />
</RadioGroup>
```
For select labels and display values, prefer the Base UI `items` collection pattern so the root, value display, and item list share the same typed values. Avoid helpers that stringify values only to recover labels later; convert values to strings only at real boundaries such as form submission, URL/search params, or legacy APIs that require strings.
`CheckboxGroup` follows the Base UI contract and uses `string[]`. Do not add a generic checkbox-group wrapper unless the underlying primitive contract changes; if different business IDs need stronger separation, model that at the feature/domain type boundary.
For complex business forms, keep state ownership outside these primitives. TanStack Form, zod, server validation, dialog reset behavior, and schema-driven rendering belong to the feature layer in `web/`; they should pass `name`, `invalid`, `dirty`, `touched`, `value`, `onValueChange`, and errors into these primitives rather than replacing the field semantics. In this repo, `web/app/components/base/form` is the TanStack/schema runtime adapter; `packages/dify-ui` remains the primitive layer.
Migration rule for `web/`: if a UI has a save/submit action, do not leave it as unrelated `Input` and `Button` pieces. Give it a real submit boundary with `Form` or a native `<form>`, attach visible field names through the appropriate label primitive (`FieldLabel`, `SelectLabel`, `SliderLabel`, or `FieldsetLegend`), expose helper/error text through `FieldDescription` / `FieldError`, and keep non-submit buttons as `type="button"`.

View File

@ -80,13 +80,13 @@ function BooleanInlineDemo() {
<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} />
<Radio<boolean> value={true} />
True
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
<Radio value={false} />
<Radio<boolean> value={false} />
False
</FieldLabel>
</FieldItem>

View File

@ -15,7 +15,7 @@ export function RadioGroup<Value = string>({
...props
}: RadioGroupProps<Value>) {
return (
<BaseRadioGroup
<BaseRadioGroup<Value>
className={cn('flex items-center gap-2', className)}
{...props}
/>

View File

@ -55,6 +55,21 @@ function TestRadioOption({
)
}
function RadioTypeExamples() {
return (
<RadioGroup<boolean> value={true} onValueChange={() => {}}>
<Radio<boolean> value={true} />
<RadioRoot<boolean> value={false} />
{/* @ts-expect-error boolean radio items should not accept string values */}
<Radio<boolean> value="true" />
{/* @ts-expect-error boolean radio roots should not accept string values */}
<RadioRoot<boolean> value="false" />
</RadioGroup>
)
}
void RadioTypeExamples
describe('Radio', () => {
it('should render unchecked and checked radios with Base UI semantics', async () => {
const screen = await render(

View File

@ -36,7 +36,7 @@ export function RadioRoot<Value = string>({
...props
}: RadioRootProps<Value>) {
return (
<BaseRadio.Root
<BaseRadio.Root<Value>
className={cn(variant === 'control' && radioRootClassName, className)}
{...props}
/>
@ -83,7 +83,7 @@ export type RadioProps<Value = string>
export function Radio<Value = string>({
...props
}: RadioProps<Value>) {
return <RadioRoot {...props} />
return <RadioRoot<Value> {...props} />
}
export type RadioSkeletonProps

View File

@ -162,7 +162,7 @@ const FollowUpSettingModal = ({
{t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' })}
</FieldsetLegend>
<FieldItem>
<RadioRoot
<RadioRoot<PromptMode>
value={PROMPT_MODE.default}
variant="unstyled"
nativeButton
@ -195,7 +195,7 @@ const FollowUpSettingModal = ({
</RadioRoot>
</FieldItem>
<FieldItem>
<RadioRoot
<RadioRoot<PromptMode>
value={PROMPT_MODE.custom}
variant="unstyled"
nativeButton

View File

@ -4,6 +4,25 @@ import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import RadioCard from '../index'
type ExampleMode = 'standard' | 'advanced'
function RadioCardTypeExamples() {
return (
<RadioGroup<ExampleMode> value="standard" onValueChange={() => {}}>
<RadioCard<ExampleMode>
value="advanced"
icon={<span>i</span>}
title="Advanced"
description="Typed option"
/>
{/* @ts-expect-error RadioCard values should stay within the selected RadioGroup value type */}
<RadioCard<ExampleMode> value="invalid" icon={<span>i</span>} title="Invalid" description="Invalid option" />
</RadioGroup>
)
}
void RadioCardTypeExamples
function renderSelectableCard({
selected = false,
onValueChange = vi.fn(),

View File

@ -14,9 +14,9 @@ type BaseProps = {
chosenConfigWrapClassName?: string
}
type SelectableRadioCardProps = BaseProps & {
type SelectableRadioCardProps<Value = string> = BaseProps & {
noRadio?: false
} & Omit<RadioRootProps<string>, 'children' | 'className' | 'variant' | 'render' | 'nativeButton'>
} & Omit<RadioRootProps<Value>, 'children' | 'className' | 'variant' | 'render' | 'nativeButton'>
type StaticRadioCardProps = BaseProps & {
noRadio: true
@ -24,9 +24,9 @@ type StaticRadioCardProps = BaseProps & {
checked?: never
}
type Props = SelectableRadioCardProps | StaticRadioCardProps
type Props<Value = string> = SelectableRadioCardProps<Value> | StaticRadioCardProps
function RadioCard(props: Props) {
function RadioCard<Value = string>(props: Props<Value>) {
if (props.noRadio) {
const {
icon,
@ -106,7 +106,7 @@ function RadioCard(props: Props) {
<div
className={rootClassName}
>
<RadioRoot
<RadioRoot<Value>
{...radioRootProps}
variant="unstyled"
nativeButton

View File

@ -234,7 +234,7 @@ const RetrievalParamConfig: FC<Props> = ({
>
{
rerankingModeOptions.map(option => (
<RadioCard
<RadioCard<RerankingModeEnum>
key={option.value}
value={option.value}
icon={(

View File

@ -122,7 +122,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
onValueChange={value => onChunkForContextChange(value)}
className="mt-1 flex-col items-stretch gap-2"
>
<RadioCard
<RadioCard<ParentMode>
value="paragraph"
icon={<img src={Note.src} alt="" />}
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
@ -142,7 +142,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
</div>
)}
/>
<RadioCard
<RadioCard<ParentMode>
value="full-doc"
icon={<img src={FileList.src} alt="" />}
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}

View File

@ -368,13 +368,13 @@ function Form<
<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} />
<Radio<boolean> value={true} />
True
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
<Radio value={false} />
<Radio<boolean> value={false} />
False
</FieldLabel>
</FieldItem>

View File

@ -270,13 +270,13 @@ function ParameterItem({
<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} />
<Radio<boolean> 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} />
<Radio<boolean> value={false} />
False
</FieldLabel>
</FieldItem>

View File

@ -71,7 +71,7 @@ export function PermissionQuickPanel({
const optionLabel = t(`privilege.${option}`, { ns: 'plugin' })
return (
<RadioRoot
<RadioRoot<PermissionType>
key={option}
value={option}
variant="unstyled"

View File

@ -30,13 +30,13 @@ type Props = Readonly<{
onHide: () => void
}>
type ItemProps = {
type ItemProps<Value = string> = {
text: string
value: AuthType | AuthHeaderPrefix
value: Value
isChecked: boolean
}
function SelectItem({ text, value, isChecked }: ItemProps) {
function SelectItem<Value = string>({ text, value, isChecked }: ItemProps<Value>) {
return (
<FieldItem>
<FieldLabel
@ -45,7 +45,7 @@ function SelectItem({ text, value, isChecked }: ItemProps) {
'flex h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl bg-components-panel-on-panel-item-bg px-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} />
<Radio<Value> value={value} />
<div className="min-w-0 truncate system-sm-regular text-text-primary">{text}</div>
</FieldLabel>
</FieldItem>
@ -137,17 +137,17 @@ export default function ConfigCredential({
<FieldsetLegend className="col-span-full py-2 system-sm-medium text-text-primary">
{t('createTool.authMethod.type', { ns: 'tools' })}
</FieldsetLegend>
<SelectItem
<SelectItem<AuthType>
text={t('createTool.authMethod.types.none', { ns: 'tools' })}
value={AuthType.none}
isChecked={tempCredential.auth_type === AuthType.none}
/>
<SelectItem
<SelectItem<AuthType>
text={t('createTool.authMethod.types.api_key_header', { ns: 'tools' })}
value={AuthType.apiKeyHeader}
isChecked={tempCredential.auth_type === AuthType.apiKeyHeader}
/>
<SelectItem
<SelectItem<AuthType>
text={t('createTool.authMethod.types.api_key_query', { ns: 'tools' })}
value={AuthType.apiKeyQuery}
isChecked={tempCredential.auth_type === AuthType.apiKeyQuery}
@ -169,17 +169,17 @@ export default function ConfigCredential({
<FieldsetLegend className="col-span-full py-2 system-sm-medium text-text-primary">
{t('createTool.authHeaderPrefix.title', { ns: 'tools' })}
</FieldsetLegend>
<SelectItem
<SelectItem<AuthHeaderPrefix>
text={t('createTool.authHeaderPrefix.types.basic', { ns: 'tools' })}
value={AuthHeaderPrefix.basic}
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.basic}
/>
<SelectItem
<SelectItem<AuthHeaderPrefix>
text={t('createTool.authHeaderPrefix.types.bearer', { ns: 'tools' })}
value={AuthHeaderPrefix.bearer}
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.bearer}
/>
<SelectItem
<SelectItem<AuthHeaderPrefix>
text={t('createTool.authHeaderPrefix.types.custom', { ns: 'tools' })}
value={AuthHeaderPrefix.custom}
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.custom}

View File

@ -150,7 +150,7 @@ function SearchMethodRadioCard({
readonly && 'cursor-not-allowed',
)}
>
<RadioRoot
<RadioRoot<RetrievalSearchMethodEnum>
value={option.id}
variant="unstyled"
nativeButton
@ -206,7 +206,7 @@ function HybridSearchModeRadioCard({
}) {
return (
<FieldItem>
<RadioRoot
<RadioRoot<HybridSearchModeEnum>
value={option.id}
variant="unstyled"
nativeButton

View File

@ -500,7 +500,7 @@ export function AgentKnowledgeRetrievalDialog({
}}
>
{queryModeOptions.map(mode => (
<RadioRoot
<RadioRoot<KnowledgeRetrievalQueryMode>
key={mode}
value={mode}
variant="unstyled"