diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 0617301542..3cb9ce928d 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2255,9 +2255,6 @@ } }, "web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 } diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 396c42e187..325454d466 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -58,13 +58,15 @@ Utilities: ## Form contract -Dify UI's form primitives are a Base UI composition layer for native form semantics, field accessibility, and design-system styling. They are intentionally not a form state-management framework. See the upstream [Base UI Form], [Base UI Field], and [Base UI Fieldset] docs for the underlying component contracts. +Dify UI's form primitives are a Base UI composition layer for native form semantics, field accessibility, and design-system styling. They are intentionally not a form state-management framework. See the upstream [Base UI forms handbook], [Base UI Form], [Base UI Field], and [Base UI Fieldset] docs for the underlying component contracts. Use `Form` for the submit boundary. It renders a native `
`, preserves Enter-to-submit and submit-button behavior, and adds Base UI's `onFormSubmit`, `errors`, `actionsRef`, and `validationMode` APIs for structured values and consolidated field validation. Prefer it over a bare `` when the form is composed with Dify UI fields. -Use `FieldRoot` for each named field. A field must have a stable `name`, a visible `FieldLabel`, and either a `FieldControl` or another control that participates in the same Base UI field context. `FieldLabel`, `FieldDescription`, and `FieldError` provide the label and message relationships that screen readers need, while the Dify wrapper adds the default Form Input Set styling from the design system. +Use `FieldRoot` for each standalone named field. A field must have a stable `name`, a label relationship, and either a `FieldControl` or another control that participates in the same Base UI field context. Prefer a visible label for normal form rows; when the surrounding UI already supplies the visible text, use the matching label primitive visually hidden or put `aria-label` on the actual interactive control. `FieldDescription` and `FieldError` provide the message relationships that screen readers need, while the Dify wrapper adds the default Form Input Set styling from the design system. -Use `FieldsetRoot` and `FieldsetLegend` when one field is represented by a group of related controls, such as checkbox groups, radio groups, or multi-thumb sliders. Compose group controls with the Base UI pattern: +Choose the label primitive by the control semantics. Text-like inputs, input-based `Combobox` / `Autocomplete`, single `Checkbox` / `Radio`, `Switch`, and `NumberField` use `FieldLabel`. Trigger-based `Select` fields use `SelectLabel`; `Slider` fields use `SliderLabel`, with per-thumb `aria-label` only when the thumbs need distinct names. `SelectGroupLabel` and `AutocompleteGroupLabel` only label grouped options inside their popup content; they are not field labels. + +Use `FieldsetRoot` and `FieldsetLegend` when one field is represented by a group of related controls, such as checkbox groups, radio groups, multi-thumb sliders, or a section that combines several inputs. For checkbox and radio groups, wrap each option with `FieldItem` and give each option its own label: ```tsx @@ -82,9 +84,9 @@ 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. -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. +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 ``, attach visible field names through `FieldLabel`, expose helper/error text through `FieldDescription` / `FieldError`, and keep non-submit buttons as `type="button"`. +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 ``, 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"`. ## Tailwind CSS v4 integration @@ -180,5 +182,6 @@ See `[AGENTS.md](./AGENTS.md)` for: [Base UI Form]: https://base-ui.com/react/components/form [Base UI Portal]: https://base-ui.com/react/overview/quick-start#portals [Base UI docs index]: https://base-ui.com/llms.txt +[Base UI forms handbook]: https://base-ui.com/react/handbook/forms [Base UI]: https://base-ui.com/react [Overlay & portal contract]: #overlay--portal-contract diff --git a/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx b/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx index 5c56dc4c07..72ed042033 100644 --- a/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx @@ -6,12 +6,12 @@ import { AutocompleteContent, AutocompleteEmpty, AutocompleteGroup, + AutocompleteGroupLabel, AutocompleteInput, AutocompleteInputGroup, AutocompleteItem, AutocompleteItemIndicator, AutocompleteItemText, - AutocompleteLabel, AutocompleteList, AutocompleteSeparator, AutocompleteStatus, @@ -230,7 +230,7 @@ describe('Autocomplete wrappers', () => { - Resources + Resources Workflow diff --git a/packages/dify-ui/src/autocomplete/index.stories.tsx b/packages/dify-ui/src/autocomplete/index.stories.tsx index 71c7c6607d..79f8983cd2 100644 --- a/packages/dify-ui/src/autocomplete/index.stories.tsx +++ b/packages/dify-ui/src/autocomplete/index.stories.tsx @@ -10,11 +10,11 @@ import { AutocompleteContent, AutocompleteEmpty, AutocompleteGroup, + AutocompleteGroupLabel, AutocompleteInput, AutocompleteInputGroup, AutocompleteItem, AutocompleteItemText, - AutocompleteLabel, AutocompleteList, AutocompleteSeparator, AutocompleteStatus, @@ -232,7 +232,7 @@ const GroupedSuggestionList = () => { {groups.map((group, groupIndex) => ( {groupIndex > 0 && } - {group.label} + {group.label} {(item: Suggestion) => ( @@ -252,7 +252,7 @@ const CommandPaletteList = () => { {groups.map((group, groupIndex) => ( {groupIndex > 0 && } - {group.label} + {group.label} {(item: Suggestion) => ( @@ -475,7 +475,7 @@ const FuzzyMatchingDemo = () => { } const meta = { - title: 'Base/UI/Autocomplete', + title: 'Base/Form/Autocomplete', component: Autocomplete, parameters: { layout: 'centered', @@ -599,7 +599,7 @@ export const LimitResults: Story = { export const CommandPalette: Story = { render: () => ( -
+
) diff --git a/packages/dify-ui/src/field/index.tsx b/packages/dify-ui/src/field/index.tsx index c6b871914e..26ab863b4a 100644 --- a/packages/dify-ui/src/field/index.tsx +++ b/packages/dify-ui/src/field/index.tsx @@ -4,7 +4,7 @@ import type { Field as BaseFieldNS } from '@base-ui/react/field' import type { VariantProps } from 'class-variance-authority' import { Field as BaseField } from '@base-ui/react/field' import { cn } from '../cn' -import { textControlVariants } from '../text-control-variants' +import { formLabelClassName, textControlVariants } from '../form-control-shared' export type FieldRootProps = Omit @@ -56,7 +56,7 @@ export function FieldLabel({ }: FieldLabelProps) { return ( ) diff --git a/packages/dify-ui/src/text-control-variants.ts b/packages/dify-ui/src/form-control-shared.ts similarity index 92% rename from packages/dify-ui/src/text-control-variants.ts rename to packages/dify-ui/src/form-control-shared.ts index 2943c00cd7..d8454fce52 100644 --- a/packages/dify-ui/src/text-control-variants.ts +++ b/packages/dify-ui/src/form-control-shared.ts @@ -1,5 +1,7 @@ import { cva } from 'class-variance-authority' +export const formLabelClassName = 'w-fit py-1 text-text-secondary system-sm-medium data-disabled:cursor-not-allowed' + export const textControlVariants = cva( [ 'w-full appearance-none border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]', diff --git a/packages/dify-ui/src/input/index.tsx b/packages/dify-ui/src/input/index.tsx index cabac346c1..4f48f3cdae 100644 --- a/packages/dify-ui/src/input/index.tsx +++ b/packages/dify-ui/src/input/index.tsx @@ -4,7 +4,7 @@ import type { Input as BaseInputNS } from '@base-ui/react/input' import type { VariantProps } from 'class-variance-authority' import { Input as BaseInput } from '@base-ui/react/input' import { cn } from '../cn' -import { textControlVariants } from '../text-control-variants' +import { textControlVariants } from '../form-control-shared' export type InputSize = NonNullable['size']> diff --git a/packages/dify-ui/src/radio-group/index.stories.tsx b/packages/dify-ui/src/radio-group/index.stories.tsx index c2c2451806..d28d9b06b0 100644 --- a/packages/dify-ui/src/radio-group/index.stories.tsx +++ b/packages/dify-ui/src/radio-group/index.stories.tsx @@ -17,7 +17,7 @@ const meta = { 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.', + component: 'RadioGroup primitive built on Base UI. For normal form rows, compose FieldRoot, FieldsetRoot, FieldLabel, RadioGroup, and Radio. For option cards, wrap each option in FieldItem and make the card itself a RadioRoot with variant="unstyled".', }, }, }, @@ -130,26 +130,27 @@ function OptionCardsDemo() { description: 'Write a prompt for this app and keep full control.', }, ].map(option => ( - } - 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" - > -
-
-
- {option.title} -
-
- {option.description} + + } + 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" + > +
+
+
+ {option.title} +
+
+ {option.description} +
+
-
- + + ))} @@ -161,7 +162,7 @@ export const OptionCards: Story = { 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.', + story: 'Wrap each option card in FieldItem, then use RadioRoot with variant="unstyled" when the entire card is the radio. RadioControl renders the visual dot inside the card.', }, }, }, diff --git a/packages/dify-ui/src/select/__tests__/index.spec.tsx b/packages/dify-ui/src/select/__tests__/index.spec.tsx index 2fd4e23bdb..ccdb13c61d 100644 --- a/packages/dify-ui/src/select/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/select/__tests__/index.spec.tsx @@ -1,5 +1,16 @@ import { render } from 'vitest-browser-react' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '../index' +import { + Select, + SelectContent, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectLabel, + SelectTrigger, + SelectValue, +} from '../index' const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement const renderWithSafeViewport = (ui: import('react').ReactNode) => render( @@ -84,6 +95,26 @@ describe('Select wrappers', () => { }) describe('SelectTrigger', () => { + it('should use SelectLabel as the trigger accessible name', async () => { + const screen = await renderWithSafeViewport( + , + ) + + await expect.element(screen.getByRole('combobox', { name: 'City' })).toBeInTheDocument() + await expect.element(screen.getByText('City')).toHaveClass('py-1', 'system-sm-medium', 'text-text-secondary') + }) + it('should forward native trigger props when trigger props are provided', async () => { const screen = await renderOpenSelect({ triggerProps: { @@ -179,6 +210,28 @@ describe('Select wrappers', () => { }) describe('SelectContent', () => { + it('should render SelectGroupLabel for grouped options without naming the trigger', async () => { + const screen = await renderWithSafeViewport( + , + ) + + await expect.element(screen.getByRole('combobox', { name: 'city select' })).toBeInTheDocument() + await expect.element(screen.getByText('Popular cities')).toHaveClass('custom-label') + }) + it('should use positioning attributes when placement is not provided', async () => { const screen = await renderOpenSelect() diff --git a/packages/dify-ui/src/select/index.stories.tsx b/packages/dify-ui/src/select/index.stories.tsx index 697266dcec..40461be199 100644 --- a/packages/dify-ui/src/select/index.stories.tsx +++ b/packages/dify-ui/src/select/index.stories.tsx @@ -4,6 +4,7 @@ import { Select, SelectContent, SelectGroup, + SelectGroupLabel, SelectItem, SelectItemIndicator, SelectItemText, @@ -62,6 +63,29 @@ export const Default: Story = { ), } +export const WithVisibleLabel: Story = { + render: () => ( +
+ +
+ ), +} + export const WithPlaceholder: Story = { render: () => (
@@ -123,7 +147,7 @@ export const WithGroupsAndSeparator: Story = { - OpenAI + OpenAI GPT-5 @@ -135,7 +159,7 @@ export const WithGroupsAndSeparator: Story = { - Anthropic + Anthropic Claude Opus @@ -147,7 +171,7 @@ export const WithGroupsAndSeparator: Story = { - Google + Google Gemini 2.5 diff --git a/packages/dify-ui/src/select/index.tsx b/packages/dify-ui/src/select/index.tsx index f736e786ed..3dd145be98 100644 --- a/packages/dify-ui/src/select/index.tsx +++ b/packages/dify-ui/src/select/index.tsx @@ -6,6 +6,7 @@ import type { Placement } from '../placement' import { Select as BaseSelect } from '@base-ui/react/select' import { cva } from 'class-variance-authority' import { cn } from '../cn' +import { formLabelClassName } from '../form-control-shared' import { overlayLabelClassName, overlayPopupAnimationClassName, @@ -71,6 +72,18 @@ export function SelectTrigger({ export function SelectLabel({ className, ...props +}: BaseSelect.Label.Props) { + return ( + + ) +} + +export function SelectGroupLabel({ + className, + ...props }: BaseSelect.GroupLabel.Props) { return ( element as HTMLElement @@ -77,4 +85,21 @@ describe('Slider', () => { expect(screen.container.querySelector('script')).not.toBeInTheDocument() }) + + it('should expose SliderLabel for composed slider fields', async () => { + const screen = await render( + + Temperature + + + + + + + , + ) + + await expect.element(screen.getByRole('slider', { name: 'Temperature' })).toHaveAttribute('aria-valuenow', '50') + await expect.element(screen.getByText('Temperature')).toHaveClass('py-1', 'system-sm-medium', 'text-text-secondary') + }) }) diff --git a/packages/dify-ui/src/slider/index.stories.tsx b/packages/dify-ui/src/slider/index.stories.tsx index 844a984406..11b22f0de3 100644 --- a/packages/dify-ui/src/slider/index.stories.tsx +++ b/packages/dify-ui/src/slider/index.stories.tsx @@ -1,7 +1,15 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type * as React from 'react' import { useState } from 'react' -import { Slider } from '.' +import { + Slider, + SliderControl, + SliderIndicator, + SliderLabel, + SliderRoot, + SliderThumb, + SliderTrack, +} from '.' const meta = { title: 'Base/Form/Slider', @@ -90,3 +98,17 @@ export const Disabled: Story = { disabled: true, }, } + +export const ComposedWithLabel: Story = { + render: () => ( + + Temperature + + + + + + + + ), +} diff --git a/packages/dify-ui/src/slider/index.tsx b/packages/dify-ui/src/slider/index.tsx index eafcecf751..23719e5c0d 100644 --- a/packages/dify-ui/src/slider/index.tsx +++ b/packages/dify-ui/src/slider/index.tsx @@ -2,9 +2,22 @@ import { Slider as BaseSlider } from '@base-ui/react/slider' import { cn } from '../cn' +import { formLabelClassName } from '../form-control-shared' export const SliderRoot = BaseSlider.Root +export function SliderLabel({ + className, + ...props +}: BaseSlider.Label.Props) { + return ( + + ) +} + type SliderRootProps = BaseSlider.Root.Props const sliderControlClassName = cn( diff --git a/web/__mocks__/base-ui-select.tsx b/web/__mocks__/base-ui-select.tsx index 7655164419..a695bebe14 100644 --- a/web/__mocks__/base-ui-select.tsx +++ b/web/__mocks__/base-ui-select.tsx @@ -61,5 +61,6 @@ export const SelectItem = ({ export const SelectItemText = ({ children }: { children?: ReactNode }) => <>{children} export const SelectItemIndicator = ({ children }: { children?: ReactNode }) => <>{children} export const SelectGroup = ({ children }: { children?: ReactNode }) => <>{children} -export const SelectLabel = ({ children }: { children?: ReactNode }) => <>{children} +export const SelectLabel = () => null +export const SelectGroupLabel = ({ children }: { children?: ReactNode }) => <>{children} export const SelectSeparator = (props: React.HTMLAttributes) =>
diff --git a/web/app/components/app/configuration/config/agent/agent-setting/index.tsx b/web/app/components/app/configuration/config/agent/agent-setting/index.tsx index 3c0ae1befc..51f4a16a25 100644 --- a/web/app/components/app/configuration/config/agent/agent-setting/index.tsx +++ b/web/app/components/app/configuration/config/agent/agent-setting/index.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { AgentConfig } from '@/models/debug' import { Button } from '@langgenius/dify-ui/button' +import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset' import { Slider } from '@langgenius/dify-ui/slider' import { RiCloseLine } from '@remixicon/react' import { useClickAway } from 'ahooks' @@ -34,6 +35,7 @@ const AgentSetting: FC = ({ const [tempPayload, setTempPayload] = useState(payload) const ref = useRef(null) const [mounted, setMounted] = useState(false) + const maximumIterationsLabel = t('agent.setting.maximumIterations.name', { ns: 'appDebug' }) useClickAway(() => { if (mounted) @@ -96,10 +98,11 @@ const AgentSetting: FC = ({ icon={ } - name={t('agent.setting.maximumIterations.name', { ns: 'appDebug' })} + name={maximumIterationsLabel} description={t('agent.setting.maximumIterations.description', { ns: 'appDebug' })} > -
+ + {maximumIterationsLabel} = ({ max_iteration: value, }) }} - aria-label={t('agent.setting.maximumIterations.name', { ns: 'appDebug' })} + aria-label={maximumIterationsLabel} /> = ({ }) }} /> -
+ {!isFunctionCall && ( diff --git a/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx index c99f20f842..e549c6ce47 100644 --- a/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx @@ -8,7 +8,7 @@ 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 { FieldItem, 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' @@ -161,70 +161,74 @@ const FollowUpSettingModal = ({ {t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' })} - } - 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', - )} - > -
-
-
- {t('feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOption', { ns: 'appDebug' })} -
-
- {t('feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOptionDescription', { ns: 'appDebug' })} + + } + 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', + )} + > +
+
+
+ {t('feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOption', { ns: 'appDebug' })} +
+
+ {t('feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOptionDescription', { ns: 'appDebug' })} +
+
-
- {promptMode === PROMPT_MODE.default && ( -
-
- {DEFAULT_FOLLOW_UP_PROMPT} + {promptMode === PROMPT_MODE.default && ( +
+
+ {DEFAULT_FOLLOW_UP_PROMPT} +
+ )} + + + + } + 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', + )} + > +
+
+
+ {t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOption', { ns: 'appDebug' })} +
+
+ {t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOptionDescription', { ns: 'appDebug' })} +
+
+
- )} -
- } - 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', - )} - > -
-
-
- {t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOption', { ns: 'appDebug' })} -
-
- {t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOptionDescription', { ns: 'appDebug' })} -
-
-
- {promptMode === PROMPT_MODE.custom && ( -