fix(dify-ui): align form label guidance (#36510)

This commit is contained in:
yyh 2026-05-22 15:29:57 +08:00 committed by GitHub
parent 157e6244dd
commit 93b7a81071
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 622 additions and 298 deletions

View File

@ -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
}

View File

@ -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 `<form>`, 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 `<form>` 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
<FieldRoot name="allowedNetworkProtocols">
@ -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 `<form>`, 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 `<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"`.
## 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

View File

@ -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', () => {
<AutocompleteContent popupProps={{ 'role': 'dialog', 'aria-label': 'autocomplete popup' }}>
<AutocompleteList role="listbox" aria-label="autocomplete list">
<AutocompleteGroup items={['workflow']}>
<AutocompleteLabel className="custom-label">Resources</AutocompleteLabel>
<AutocompleteGroupLabel className="custom-label">Resources</AutocompleteGroupLabel>
<AutocompleteSeparator className="custom-separator" data-testid="separator" />
<AutocompleteItem value="workflow" className="custom-item">
<AutocompleteItemText className="custom-text">Workflow</AutocompleteItemText>

View File

@ -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) => (
<AutocompleteGroup key={group.label} items={group.items}>
{groupIndex > 0 && <AutocompleteSeparator />}
<AutocompleteLabel>{group.label}</AutocompleteLabel>
<AutocompleteGroupLabel>{group.label}</AutocompleteGroupLabel>
<AutocompleteCollection>
{(item: Suggestion) => (
<SuggestionItem key={item.value} item={item} />
@ -252,7 +252,7 @@ const CommandPaletteList = () => {
{groups.map((group, groupIndex) => (
<AutocompleteGroup key={group.label} items={group.items}>
{groupIndex > 0 && <AutocompleteSeparator />}
<AutocompleteLabel>{group.label}</AutocompleteLabel>
<AutocompleteGroupLabel>{group.label}</AutocompleteGroupLabel>
<AutocompleteCollection>
{(item: Suggestion) => (
<AutocompleteItem key={item.value} value={item} className="grid grid-cols-[1fr_auto]">
@ -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: () => (
<div className="w-[440px] rounded-xl border border-divider-subtle bg-components-panel-bg-alt p-2 shadow-xs">
<div className="w-110 rounded-xl border border-divider-subtle bg-components-panel-bg-alt p-2 shadow-xs">
<Autocomplete
open
inline

View File

@ -316,7 +316,7 @@ export function AutocompleteItemText({
)
}
export function AutocompleteLabel({
export function AutocompleteGroupLabel({
className,
...props
}: BaseAutocomplete.GroupLabel.Props) {

View File

@ -6,6 +6,7 @@ import type { Placement } from '../placement'
import { Combobox as BaseCombobox } from '@base-ui/react/combobox'
import { cva } from 'class-variance-authority'
import { cn } from '../cn'
import { formLabelClassName } from '../form-control-shared'
import {
overlayIndicatorClassName,
overlayLabelClassName,
@ -399,7 +400,7 @@ export function ComboboxLabel({
}: BaseCombobox.Label.Props) {
return (
<BaseCombobox.Label
className={cn('mb-1 block text-text-secondary system-sm-medium', className)}
className={cn(formLabelClassName, className)}
{...props}
/>
)

View File

@ -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<BaseFieldNS.Root.Props, 'className'>
@ -56,7 +56,7 @@ export function FieldLabel({
}: FieldLabelProps) {
return (
<BaseField.Label
className={cn('w-fit py-1 text-text-secondary system-sm-medium data-disabled:cursor-not-allowed', className)}
className={cn(formLabelClassName, className)}
{...props}
/>
)

View File

@ -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]',

View File

@ -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<VariantProps<typeof textControlVariants>['size']>

View File

@ -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 => (
<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}
<FieldItem key={option.value}>
<RadioRoot
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>
<RadioControl aria-hidden="true" />
</div>
</RadioRoot>
</RadioRoot>
</FieldItem>
))}
</FieldsetRoot>
</FieldRoot>
@ -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.',
},
},
},

View File

@ -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(
<Select defaultValue="seattle">
<SelectLabel>City</SelectLabel>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>,
)
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(
<Select open defaultValue="seattle">
<SelectTrigger aria-label="city select">
<SelectValue />
</SelectTrigger>
<SelectContent listProps={{ 'role': 'listbox', 'aria-label': 'select list' }}>
<SelectGroup>
<SelectGroupLabel className="custom-label">Popular cities</SelectGroupLabel>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>,
)
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()

View File

@ -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: () => (
<div className={triggerWidth}>
<Select defaultValue="seattle">
<SelectLabel>City</SelectLabel>
<SelectTrigger>
<SelectValue placeholder="Select a city" />
</SelectTrigger>
<SelectContent>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="new-york">
<SelectItemText>New York</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>
</div>
),
}
export const WithPlaceholder: Story = {
render: () => (
<div className={triggerWidth}>
@ -123,7 +147,7 @@ export const WithGroupsAndSeparator: Story = {
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>OpenAI</SelectLabel>
<SelectGroupLabel>OpenAI</SelectGroupLabel>
<SelectItem value="gpt-5">
<SelectItemText>GPT-5</SelectItemText>
<SelectItemIndicator />
@ -135,7 +159,7 @@ export const WithGroupsAndSeparator: Story = {
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Anthropic</SelectLabel>
<SelectGroupLabel>Anthropic</SelectGroupLabel>
<SelectItem value="claude-opus">
<SelectItemText>Claude Opus</SelectItemText>
<SelectItemIndicator />
@ -147,7 +171,7 @@ export const WithGroupsAndSeparator: Story = {
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Google</SelectLabel>
<SelectGroupLabel>Google</SelectGroupLabel>
<SelectItem value="gemini-25">
<SelectItemText>Gemini 2.5</SelectItemText>
<SelectItemIndicator />

View File

@ -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 (
<BaseSelect.Label
className={cn(formLabelClassName, className)}
{...props}
/>
)
}
export function SelectGroupLabel({
className,
...props
}: BaseSelect.GroupLabel.Props) {
return (
<BaseSelect.GroupLabel

View File

@ -1,5 +1,13 @@
import { render } from 'vitest-browser-react'
import { Slider } from '../index'
import {
Slider,
SliderControl,
SliderIndicator,
SliderLabel,
SliderRoot,
SliderThumb,
SliderTrack,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => 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(
<SliderRoot defaultValue={50}>
<SliderLabel>Temperature</SliderLabel>
<SliderControl>
<SliderTrack>
<SliderIndicator />
</SliderTrack>
<SliderThumb />
</SliderControl>
</SliderRoot>,
)
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')
})
})

View File

@ -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: () => (
<SliderRoot defaultValue={50} className="group/slider relative inline-flex w-[320px] flex-col gap-1 data-disabled:opacity-30">
<SliderLabel>Temperature</SliderLabel>
<SliderControl>
<SliderTrack>
<SliderIndicator />
</SliderTrack>
<SliderThumb />
</SliderControl>
</SliderRoot>
),
}

View File

@ -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 (
<BaseSlider.Label
className={cn(formLabelClassName, className)}
{...props}
/>
)
}
type SliderRootProps = BaseSlider.Root.Props<number>
const sliderControlClassName = cn(

View File

@ -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<HTMLDivElement>) => <div role="separator" {...props} />

View File

@ -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<Props> = ({
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<Props> = ({
icon={
<Unblur className="h-4 w-4 text-[#FB6514]" />
}
name={t('agent.setting.maximumIterations.name', { ns: 'appDebug' })}
name={maximumIterationsLabel}
description={t('agent.setting.maximumIterations.description', { ns: 'appDebug' })}
>
<div className="flex items-center">
<FieldsetRoot className="flex items-center">
<FieldsetLegend className="sr-only">{maximumIterationsLabel}</FieldsetLegend>
<Slider
className="mr-3 w-[156px]"
min={maxIterationsMin}
@ -111,10 +114,11 @@ const AgentSetting: FC<Props> = ({
max_iteration: value,
})
}}
aria-label={t('agent.setting.maximumIterations.name', { ns: 'appDebug' })}
aria-label={maximumIterationsLabel}
/>
<input
aria-label={maximumIterationsLabel}
type="number"
min={maxIterationsMin}
max={MAX_ITERATIONS_NUM}
@ -134,7 +138,7 @@ const AgentSetting: FC<Props> = ({
})
}}
/>
</div>
</FieldsetRoot>
</ItemPanel>
{!isFunctionCall && (

View File

@ -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 = ({
<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',
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="system-sm-semibold text-text-primary">
{t('feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOption', { ns: 'appDebug' })}
</div>
<div className="mt-1 system-xs-regular text-text-tertiary">
{t('feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOptionDescription', { ns: 'appDebug' })}
<FieldItem>
<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',
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="system-sm-semibold text-text-primary">
{t('feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOption', { ns: 'appDebug' })}
</div>
<div className="mt-1 system-xs-regular text-text-tertiary">
{t('feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOptionDescription', { ns: 'appDebug' })}
</div>
</div>
<RadioControl aria-hidden="true" />
</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">
<div className="system-sm-regular wrap-break-word whitespace-pre-wrap text-text-secondary">
{DEFAULT_FOLLOW_UP_PROMPT}
{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">
<div className="system-sm-regular wrap-break-word whitespace-pre-wrap text-text-secondary">
{DEFAULT_FOLLOW_UP_PROMPT}
</div>
</div>
)}
</RadioRoot>
</FieldItem>
<FieldItem>
<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',
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="system-sm-semibold text-text-primary">
{t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOption', { ns: 'appDebug' })}
</div>
<div className="mt-1 system-xs-regular text-text-tertiary">
{t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOptionDescription', { ns: 'appDebug' })}
</div>
</div>
<RadioControl aria-hidden="true" />
</div>
)}
</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',
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="system-sm-semibold text-text-primary">
{t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOption', { ns: 'appDebug' })}
</div>
<div className="mt-1 system-xs-regular text-text-tertiary">
{t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOptionDescription', { ns: 'appDebug' })}
</div>
</div>
<RadioControl aria-hidden="true" />
</div>
{promptMode === PROMPT_MODE.custom && (
<Textarea
className="mt-3 min-h-32 resize-y border-components-input-border-active bg-components-input-bg-normal"
value={prompt}
onChange={e => setPrompt(e.target.value)}
maxLength={CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH}
placeholder={t('feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder', { ns: 'appDebug' }) || ''}
/>
)}
</RadioRoot>
{promptMode === PROMPT_MODE.custom && (
<Textarea
className="mt-3 min-h-32 resize-y border-components-input-border-active bg-components-input-bg-normal"
value={prompt}
onChange={e => setPrompt(e.target.value)}
maxLength={CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH}
placeholder={t('feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder', { ns: 'appDebug' }) || ''}
/>
)}
</RadioRoot>
</FieldItem>
</FieldsetRoot>
</FieldRoot>
</div>

View File

@ -22,7 +22,7 @@ describe('NumberInputField', () => {
it('should render current number value', () => {
render(<NumberInputField label="Count" />)
expect(screen.getByRole('textbox')).toHaveValue('2')
expect(screen.getByLabelText('Count')).toHaveValue('2')
})
it('should update value when users click increment', () => {
@ -33,14 +33,14 @@ describe('NumberInputField', () => {
it('should reset field value when users clear the input', () => {
render(<NumberInputField label="Count" />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } })
fireEvent.change(screen.getByLabelText('Count'), { target: { value: '' } })
expect(mockField.handleChange).toHaveBeenCalledWith(0)
})
it('should clamp out-of-range edits before updating field state', () => {
render(<NumberInputField label="Count" min={0} max={10} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '12' } })
fireEvent.change(screen.getByLabelText('Count'), { target: { value: '12' } })
expect(mockField.handleChange).toHaveBeenLastCalledWith(10)
})

View File

@ -15,14 +15,16 @@ vi.mock('../../..', () => ({
vi.mock('@/app/components/workflow/nodes/_base/components/input-number-with-slider', () => ({
default: ({
label,
value,
onChange,
}: {
label: string
value: number
onChange: (value: number) => void
}) => (
<button onClick={() => onChange(value + 1)}>
{`slider-value-${value}`}
{`${label}-slider-value-${value}`}
</button>
),
}))
@ -40,7 +42,7 @@ describe('NumberSliderField', () => {
it('should update value when users interact with slider', () => {
render(<NumberSliderField label="Threshold" />)
fireEvent.click(screen.getByRole('button', { name: 'slider-value-2' }))
fireEvent.click(screen.getByRole('button', { name: 'Threshold-slider-value-2' }))
expect(mockField.handleChange).toHaveBeenCalledWith(3)
})
})

View File

@ -56,7 +56,6 @@ const NumberInputField = ({
{...(labelOptions ?? {})}
/>
<NumberField
id={field.name}
name={field.name}
value={field.state.value}
min={min}
@ -69,6 +68,7 @@ const NumberInputField = ({
>
<NumberFieldGroup size={size}>
<NumberFieldInput
id={field.name}
{...inputProps}
size={size}
className={inputClassName}

View File

@ -36,6 +36,7 @@ const NumberSliderField = ({
)}
</div>
<InputNumberWithSlider
label={label}
value={field.state.value}
onChange={value => field.handleChange(value)}
{...InputNumberWithSliderProps}

View File

@ -29,6 +29,12 @@ const FieldHarness = ({ config, initialData = {} }: FieldHarnessProps) => {
return <Component form={form} />
}
const getVisibleText = (text: string) => {
const element = screen.getAllByText(text).find(element => !element.classList.contains('sr-only'))
expect(element).toBeDefined()
return element!
}
describe('InputField', () => {
it('should render text input field by default', () => {
render(<FieldHarness config={createConfig({ label: 'Prompt' })} initialData={{ fieldA: '' }} />)
@ -51,7 +57,7 @@ describe('InputField', () => {
/>,
)
expect(screen.getByText('Temperature')).toBeInTheDocument()
expect(getVisibleText('Temperature')).toBeInTheDocument()
expect(screen.getByText('Control randomness')).toBeInTheDocument()
})

View File

@ -54,6 +54,12 @@ const NodePanelWrapper = ({ children }: { children: ReactNode }) => {
)
}
const getVisibleText = (text: string) => {
const element = screen.getAllByText(text).find(element => !element.classList.contains('sr-only'))
expect(element).toBeDefined()
return element!
}
describe('NodePanelField', () => {
it('should render text input field', () => {
render(<FieldHarness config={createConfig({ label: 'Node Name' })} initialData={{ fieldA: '' }} />)
@ -130,7 +136,7 @@ describe('NodePanelField', () => {
for (const scenario of scenarios) {
const { unmount } = render(<FieldHarness config={scenario.config} initialData={scenario.initialData} />)
expect(screen.getByText(scenario.config.label)).toBeInTheDocument()
expect(getVisibleText(scenario.config.label)).toBeInTheDocument()
unmount()
}

View File

@ -14,7 +14,9 @@ describe('ParamItem Slider onChange', () => {
vi.clearAllMocks()
})
const getSlider = () => screen.getByLabelText('Test Param')
const getSlider = () => screen.getByLabelText('Test Param', {
selector: 'input[type="range"]',
})
it('should divide slider value by 100 when max < 5', async () => {
const user = userEvent.setup()

View File

@ -17,13 +17,15 @@ describe('ParamItem', () => {
vi.clearAllMocks()
})
const getSlider = () => screen.getByLabelText('Test Param')
const getSlider = () => screen.getByLabelText('Test Param', {
selector: 'input[type="range"]',
})
describe('Rendering', () => {
it('should render the parameter name', () => {
render(<ParamItem {...defaultProps} />)
expect(screen.getByText('Test Param')).toBeInTheDocument()
expect(screen.getByText('Test Param', { selector: 'span' })).toBeInTheDocument()
})
it('should render a tooltip trigger by default', () => {

View File

@ -14,13 +14,15 @@ describe('ScoreThresholdItem', () => {
vi.clearAllMocks()
})
const getSlider = () => screen.getByLabelText('appDebug.datasetConfig.score_threshold')
const getSlider = () => screen.getByLabelText('appDebug.datasetConfig.score_threshold', {
selector: 'input[type="range"]',
})
describe('Rendering', () => {
it('should render the translated parameter name', () => {
render(<ScoreThresholdItem {...defaultProps} />)
expect(screen.getByText('appDebug.datasetConfig.score_threshold')).toBeInTheDocument()
expect(screen.getByText('appDebug.datasetConfig.score_threshold', { selector: 'span' })).toBeInTheDocument()
})
it('should render tooltip trigger', () => {

View File

@ -19,13 +19,15 @@ describe('TopKItem', () => {
vi.clearAllMocks()
})
const getSlider = () => screen.getByLabelText('appDebug.datasetConfig.top_k')
const getSlider = () => screen.getByLabelText('appDebug.datasetConfig.top_k', {
selector: 'input[type="range"]',
})
describe('Rendering', () => {
it('should render the translated parameter name', () => {
render(<TopKItem {...defaultProps} />)
expect(screen.getByText('appDebug.datasetConfig.top_k')).toBeInTheDocument()
expect(screen.getByText('appDebug.datasetConfig.top_k', { selector: 'span' })).toBeInTheDocument()
})
it('should render tooltip trigger', () => {

View File

@ -1,5 +1,6 @@
'use client'
import type { FC } from 'react'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import {
NumberField,
NumberFieldControls,
@ -30,7 +31,8 @@ type Props = {
const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1, min = 0, max, value, enable, onChange, hasSwitch, onSwitchChange }) => {
return (
<div className={className}>
<FieldsetRoot className={className}>
<FieldsetLegend className="sr-only">{name}</FieldsetLegend>
<div className="flex items-center justify-between">
<div className="flex h-6 items-center">
{hasSwitch && (
@ -62,7 +64,7 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1,
onValueChange={nextValue => onChange(id, nextValue ?? min)}
>
<NumberFieldGroup>
<NumberFieldInput className="w-[72px]" />
<NumberFieldInput aria-label={name} className="w-18" />
<NumberFieldControls>
<NumberFieldIncrement />
<NumberFieldDecrement />
@ -82,7 +84,7 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1,
/>
</div>
</div>
</div>
</FieldsetRoot>
)
}
export default ParamItem

View File

@ -72,12 +72,14 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = ({ tooltip,
}
type CompoundNumberInputProps = Omit<NumberFieldRootProps, 'children' | 'className' | 'onValueChange'> & Omit<NumberFieldInputProps, 'children' | 'size' | 'onChange'> & {
label: string
unit?: ReactNode
size?: NumberFieldSize
onChange: (value: number) => void
}
function CompoundNumberInput({
label,
onChange,
unit,
size = 'large',
@ -104,6 +106,7 @@ function CompoundNumberInput({
<NumberFieldGroup size={size}>
<NumberFieldInput
{...inputProps}
aria-label={label}
size={size}
className={className}
onBlur={onBlur}
@ -122,18 +125,22 @@ function CompoundNumberInput({
)
}
export const MaxLengthInput: FC<CompoundNumberInputProps> = (props) => {
type LabeledCompoundNumberInputProps = Omit<CompoundNumberInputProps, 'label'>
export const MaxLengthInput: FC<LabeledCompoundNumberInputProps> = (props) => {
const maxValue = env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH
const { t } = useTranslation()
const label = t('stepTwo.maxLength', { ns: 'datasetCreation' })
return (
<FormField label={(
<div className="mb-1 system-sm-semibold">
{t('stepTwo.maxLength', { ns: 'datasetCreation' })}
{label}
</div>
)}
>
<CompoundNumberInput
label={label}
size="large"
placeholder={`${maxValue}`}
max={maxValue}
@ -144,12 +151,13 @@ export const MaxLengthInput: FC<CompoundNumberInputProps> = (props) => {
)
}
export const OverlapInput: FC<CompoundNumberInputProps> = (props) => {
export const OverlapInput: FC<LabeledCompoundNumberInputProps> = (props) => {
const { t } = useTranslation()
const label = t('stepTwo.overlap', { ns: 'datasetCreation' })
return (
<FormField label={(
<div className="mb-1 flex items-center">
<span className="system-sm-semibold">{t('stepTwo.overlap', { ns: 'datasetCreation' })}</span>
<span className="system-sm-semibold">{label}</span>
<Infotip aria-label={t('stepTwo.overlapTip', { ns: 'datasetCreation' })} popupClassName="max-w-[200px]">
{t('stepTwo.overlapTip', { ns: 'datasetCreation' })}
</Infotip>
@ -157,8 +165,9 @@ export const OverlapInput: FC<CompoundNumberInputProps> = (props) => {
)}
>
<CompoundNumberInput
label={label}
size="large"
placeholder={t('stepTwo.overlap', { ns: 'datasetCreation' }) || ''}
placeholder={label || ''}
min={1}
{...props}
/>

View File

@ -78,6 +78,12 @@ const renderComponent = (props: Partial<React.ComponentProps<typeof ExternalKnow
return render(<ExternalKnowledgeBaseCreate {...defaultProps} {...props} />)
}
const getVisibleText = (text: string) => {
const element = screen.getAllByText(text).find(element => !element.classList.contains('sr-only'))
expect(element).toBeDefined()
return element!
}
describe('ExternalKnowledgeBaseCreate', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -878,8 +884,8 @@ describe('ExternalKnowledgeBaseCreate', () => {
expect(screen.getByText('dataset.retrievalSettings'))!.toBeInTheDocument()
// Should show Top K and Score Threshold labels
// Should show Top K and Score Threshold labels
expect(screen.getByText('appDebug.datasetConfig.top_k'))!.toBeInTheDocument()
expect(screen.getByText('appDebug.datasetConfig.score_threshold'))!.toBeInTheDocument()
expect(getVisibleText('appDebug.datasetConfig.top_k')).toBeInTheDocument()
expect(getVisibleText('appDebug.datasetConfig.score_threshold')).toBeInTheDocument()
})
})

View File

@ -92,6 +92,21 @@ describe('WrappedDatePicker', () => {
)
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument()
})
it('should include the field label in the trigger accessible name', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker label="Metadata field" onChange={handleChange} />)
expect(screen.getByRole('button', { name: 'Metadata field: dataset.metadata.chooseTime' })).toBeInTheDocument()
})
it('should include the field label in the clear button accessible name', () => {
const handleChange = vi.fn()
const timestamp = 1609459200
render(<WrappedDatePicker label="Metadata field" value={timestamp} onChange={handleChange} />)
expect(screen.getByRole('button', { name: 'Metadata field: common.operation.clear' })).toBeInTheDocument()
})
})
describe('Props', () => {

View File

@ -13,11 +13,13 @@ import useTimestamp from '@/hooks/use-timestamp'
type Props = {
className?: string
label?: string
value?: number
onChange: (date: number | null) => void
}
const WrappedDatePicker = ({
className,
label,
value,
onChange,
}: Props) => {
@ -37,11 +39,13 @@ const WrappedDatePicker = ({
}: TriggerProps) => {
const hasValue = Boolean(value)
const triggerText = value ? formatTimestamp(value, t('metadata.dateTimeFormat', { ns: 'datasetDocuments' })) : t('metadata.chooseTime', { ns: 'dataset' })
const clearLabel = t('operation.clear', { ns: 'common' })
return (
<div className={cn('group flex items-center rounded-md bg-components-input-bg-normal', className)}>
<button
type="button"
aria-label={label ? `${label}: ${triggerText}` : undefined}
className="flex min-w-0 grow items-center border-none bg-transparent p-0 text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={handleClickTrigger}
>
@ -65,7 +69,7 @@ const WrappedDatePicker = ({
? (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
aria-label={label ? `${label}: ${clearLabel}` : clearLabel}
className={cn(
'hidden size-4 cursor-pointer rounded-full border-none bg-transparent p-0 text-text-quaternary group-hover:block hover:text-components-input-text-filled focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden',
)}
@ -80,7 +84,7 @@ const WrappedDatePicker = ({
: null}
</div>
)
}, [className, value, formatTimestamp, t, handleDateChange])
}, [className, label, value, formatTimestamp, t, handleDateChange])
return (
<DatePicker

View File

@ -7,14 +7,15 @@ type DatePickerProps = {
value: number | null
onChange: (value: number) => void
className?: string
label?: string
}
// Mock the base date-picker component
vi.mock('../../base/date-picker', () => ({
default: ({ value, onChange, className }: DatePickerProps) => (
<div data-testid="date-picker" className={className} onClick={() => onChange(Date.now())}>
default: ({ value, onChange, className, label }: DatePickerProps) => (
<button type="button" aria-label={label} data-testid="date-picker" className={className} onClick={() => onChange(Date.now())}>
{value || 'Pick date'}
</div>
</button>
),
}))
@ -23,7 +24,7 @@ describe('InputCombined', () => {
it('should render without crashing', () => {
const handleChange = vi.fn()
const { container } = render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.string} value="" onChange={handleChange} />,
)
expect(container.firstChild).toBeInTheDocument()
})
@ -31,7 +32,7 @@ describe('InputCombined', () => {
it('should render text input for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.string} value="test" onChange={handleChange} />,
)
const input = screen.getByDisplayValue('test')
expect(input).toBeInTheDocument()
@ -41,9 +42,9 @@ describe('InputCombined', () => {
it('should render number input for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.number} value={42} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
const input = screen.getByRole('textbox', { name: 'Metadata field' })
expect(input).toBeInTheDocument()
expect(input).toHaveValue('42')
})
@ -51,7 +52,7 @@ describe('InputCombined', () => {
it('should render date picker for time type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.time} value={Date.now()} onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.time} value={Date.now()} onChange={handleChange} />,
)
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
})
@ -61,7 +62,7 @@ describe('InputCombined', () => {
it('should call onChange with input value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.string} value="" onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
@ -73,7 +74,7 @@ describe('InputCombined', () => {
it('should display current value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="existing value" onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.string} value="existing value" onChange={handleChange} />,
)
expect(screen.getByDisplayValue('existing value')).toBeInTheDocument()
@ -82,7 +83,7 @@ describe('InputCombined', () => {
it('should apply readOnly prop to string input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="test" onChange={handleChange} readOnly />,
<InputCombined label="Metadata field" type={DataType.string} value="test" onChange={handleChange} readOnly />,
)
const input = screen.getByRole('textbox')
@ -94,7 +95,7 @@ describe('InputCombined', () => {
it('should call onChange with number value for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.number} value={0} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
@ -106,7 +107,7 @@ describe('InputCombined', () => {
it('should reset cleared number input to 0', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.number} value={42} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
@ -118,7 +119,7 @@ describe('InputCombined', () => {
it('should display current value for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={999} onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.number} value={999} onChange={handleChange} />,
)
expect(screen.getByRole('textbox')).toHaveValue('999')
@ -127,7 +128,7 @@ describe('InputCombined', () => {
it('should apply readOnly prop to number input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={42} onChange={handleChange} readOnly />,
<InputCombined label="Metadata field" type={DataType.number} value={42} onChange={handleChange} readOnly />,
)
const input = screen.getByRole('textbox')
@ -139,16 +140,25 @@ describe('InputCombined', () => {
it('should render date picker for time type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.time} value={1234567890} onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.time} value={1234567890} onChange={handleChange} />,
)
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
})
it('should label the date picker trigger with the metadata field name', () => {
const handleChange = vi.fn()
render(
<InputCombined label="Metadata field" type={DataType.time} value={1234567890} onChange={handleChange} />,
)
expect(screen.getByRole('button', { name: 'Metadata field' })).toBeInTheDocument()
})
it('should call onChange when date is selected', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.time} value={null} onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.time} value={null} onChange={handleChange} />,
)
fireEvent.click(screen.getByTestId('date-picker'))
@ -161,6 +171,7 @@ describe('InputCombined', () => {
const handleChange = vi.fn()
const { container } = render(
<InputCombined
label="Metadata field"
type={DataType.string}
value=""
onChange={handleChange}
@ -176,7 +187,7 @@ describe('InputCombined', () => {
it('should handle null value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value={null} onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.string} value={null} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
@ -186,7 +197,7 @@ describe('InputCombined', () => {
it('should handle undefined value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value={undefined as unknown as string} onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.string} value={undefined as unknown as string} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
@ -196,7 +207,7 @@ describe('InputCombined', () => {
it('should handle null value for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={null} onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.number} value={null} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
@ -208,7 +219,7 @@ describe('InputCombined', () => {
it('should have correct base styling for string input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.string} value="" onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
@ -218,7 +229,7 @@ describe('InputCombined', () => {
it('should have correct styling for number input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.number} value={0} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
@ -230,7 +241,7 @@ describe('InputCombined', () => {
it('should handle empty string value', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.string} value="" onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
@ -240,7 +251,7 @@ describe('InputCombined', () => {
it('should handle zero value for number', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.number} value={0} onChange={handleChange} />,
)
expect(screen.getByRole('textbox')).toHaveValue('0')
@ -249,7 +260,7 @@ describe('InputCombined', () => {
it('should handle negative number', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={-100} onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.number} value={-100} onChange={handleChange} />,
)
expect(screen.getByRole('textbox')).toHaveValue('-100')
@ -258,7 +269,7 @@ describe('InputCombined', () => {
it('should handle special characters in string', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value={'<script>alert("xss")</script>'} onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.string} value={'<script>alert("xss")</script>'} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('<script>alert("xss")</script>')).toBeInTheDocument()
@ -267,13 +278,13 @@ describe('InputCombined', () => {
it('should handle switching between types', () => {
const handleChange = vi.fn()
const { rerender } = render(
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.string} value="test" onChange={handleChange} />,
)
expect(screen.getByRole('textbox')).toBeInTheDocument()
rerender(
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
<InputCombined label="Metadata field" type={DataType.number} value={42} onChange={handleChange} />,
)
expect(screen.getByRole('textbox')).toBeInTheDocument()

View File

@ -24,6 +24,7 @@ const AddRow: FC<Props> = ({
<div className={cn('flex h-6 items-center space-x-0.5', className)}>
<Label text={payload.name} />
<InputCombined
label={payload.name}
type={payload.type}
value={payload.value}
onChange={value => onChange({ ...payload, value })}

View File

@ -38,6 +38,7 @@ const EditMetadatabatchItem: FC<Props> = ({
)
: (
<InputCombined
label={payload.name}
type={payload.type}
value={payload.value}
onChange={v => onChange({ ...payload, value: v as string })}

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import {
NumberField,
NumberFieldControls,
@ -10,12 +11,12 @@ import {
NumberFieldInput,
} from '@langgenius/dify-ui/number-field'
import * as React from 'react'
import Input from '@/app/components/base/input'
import Datepicker from '../base/date-picker'
import { DataType } from '../types'
type Props = {
className?: string
label: string
type: DataType
value: any
onChange: (value: any) => void
@ -24,6 +25,7 @@ type Props = {
const InputCombined: FC<Props> = ({
className: configClassName,
label,
type,
value,
onChange,
@ -33,6 +35,7 @@ const InputCombined: FC<Props> = ({
if (type === DataType.time) {
return (
<Datepicker
label={label}
className={className}
value={value}
onChange={onChange}
@ -51,6 +54,7 @@ const InputCombined: FC<Props> = ({
>
<NumberFieldGroup>
<NumberFieldInput
aria-label={label}
className={cn(className, 'rounded-l-md')}
/>
<NumberFieldControls className="overflow-hidden">
@ -64,8 +68,8 @@ const InputCombined: FC<Props> = ({
}
return (
<Input
wrapperClassName={configClassName}
className={cn(className, 'rounded-md')}
aria-label={label}
className={cn(configClassName, className, 'rounded-md')}
value={value}
onChange={e => onChange(e.target.value)}
readOnly={readOnly}

View File

@ -5,6 +5,7 @@ import { DataType } from '../../types'
import InfoGroup from '../info-group'
type InputCombinedProps = {
label: string
value: string | number | null
onChange: (value: string | number) => void
type: DataType
@ -43,9 +44,9 @@ vi.mock('@/hooks/use-timestamp', () => ({
// Mock InputCombined
vi.mock('../../edit-metadata-batch/input-combined', () => ({
default: ({ value, onChange, type }: InputCombinedProps) => (
default: ({ label, value, onChange, type }: InputCombinedProps) => (
<input
aria-label={`Metadata ${type} value`}
aria-label={label}
data-type={type}
value={value || ''}
onChange={e => onChange(e.target.value)}

View File

@ -91,6 +91,7 @@ const InfoGroup: FC<Props> = ({
<div className="flex items-center space-x-0.5">
<InputCombined
className="h-6"
label={item.name}
type={item.type}
value={item.value}
onChange={value => onChange?.({ ...item, value })}

View File

@ -18,12 +18,12 @@ describe('KeyWordNumber', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<KeyWordNumber {...defaultProps} />)
expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument()
expect(screen.getByText(/form\.numberOfKeywords/, { selector: '.truncate' })).toBeInTheDocument()
})
it('should render label text', () => {
render(<KeyWordNumber {...defaultProps} />)
expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument()
expect(screen.getByText(/form\.numberOfKeywords/, { selector: '.truncate' })).toBeInTheDocument()
})
it('should render infotip with question icon', () => {
@ -48,7 +48,7 @@ describe('KeyWordNumber', () => {
describe('Props', () => {
it('should display correct keywordNumber value in input', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('textbox', { name: 'datasetSettings.form.numberOfKeywords' })
expect(input).toHaveValue('25')
})
@ -84,7 +84,7 @@ describe('KeyWordNumber', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('textbox', { name: 'datasetSettings.form.numberOfKeywords' })
fireEvent.change(input, { target: { value: '30' } })
expect(handleChange).toHaveBeenCalled()
@ -94,7 +94,7 @@ describe('KeyWordNumber', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('textbox', { name: 'datasetSettings.form.numberOfKeywords' })
fireEvent.change(input, { target: { value: '' } })
expect(handleChange).toHaveBeenCalledWith(0)
@ -126,13 +126,13 @@ describe('KeyWordNumber', () => {
describe('Edge Cases', () => {
it('should handle minimum value (0)', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={0} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('textbox', { name: 'datasetSettings.form.numberOfKeywords' })
expect(input).toHaveValue('0')
})
it('should handle maximum value (50)', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={50} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('textbox', { name: 'datasetSettings.form.numberOfKeywords' })
expect(input).toHaveValue('50')
})
@ -151,7 +151,7 @@ describe('KeyWordNumber', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('textbox', { name: 'datasetSettings.form.numberOfKeywords' })
// Simulate rapid changes via input with different values
fireEvent.change(input, { target: { value: '15' } })

View File

@ -1,3 +1,4 @@
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import {
NumberField,
NumberFieldControls,
@ -25,22 +26,24 @@ const KeyWordNumber = ({
onKeywordNumberChange,
}: KeyWordNumberProps) => {
const { t } = useTranslation()
const label = t('form.numberOfKeywords', { ns: 'datasetSettings' })
const handleInputChange = useCallback((value: number | null) => {
onKeywordNumberChange(value ?? MIN_KEYWORD_NUMBER)
}, [onKeywordNumberChange])
return (
<div className="flex items-center gap-x-1">
<FieldsetRoot className="flex items-center gap-x-1">
<FieldsetLegend className="sr-only">{label}</FieldsetLegend>
<div className="flex grow items-center gap-x-0.5">
<div className="truncate system-xs-medium text-text-secondary">
{t('form.numberOfKeywords', { ns: 'datasetSettings' })}
{label}
</div>
<Infotip
aria-label={t('form.numberOfKeywords', { ns: 'datasetSettings' })}
aria-label={label}
className="size-3.5"
>
{t('form.numberOfKeywords', { ns: 'datasetSettings' })}
{label}
</Infotip>
</div>
<Slider
@ -49,7 +52,7 @@ const KeyWordNumber = ({
min={MIN_KEYWORD_NUMBER}
max={MAX_KEYWORD_NUMBER}
onValueChange={onKeywordNumberChange}
aria-label={t('form.numberOfKeywords', { ns: 'datasetSettings' })}
aria-label={label}
/>
<NumberField
className="w-[74px] shrink-0"
@ -59,14 +62,14 @@ const KeyWordNumber = ({
onValueChange={handleInputChange}
>
<NumberFieldGroup>
<NumberFieldInput className="w-12 flex-none px-2 text-center" />
<NumberFieldInput aria-label={label} className="w-12 flex-none px-2 text-center" />
<NumberFieldControls>
<NumberFieldIncrement />
<NumberFieldDecrement />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</div>
</FieldsetRoot>
)
}

View File

@ -72,6 +72,7 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
),
SelectGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectGroupLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({
children,
value,

View File

@ -4,9 +4,9 @@ import {
Select,
SelectContent,
SelectGroup,
SelectGroupLabel,
SelectItem,
SelectItemText,
SelectLabel,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
@ -50,9 +50,9 @@ export const WorkplaceSelectorContent = memo(({
return (
<SelectContent popupClassName={popupClassName}>
<SelectGroup>
<SelectLabel>
<SelectGroupLabel>
{t('userProfile.workspace', { ns: 'common' })}
</SelectLabel>
</SelectGroupLabel>
{workspaces.map(workspace => (
<WorkplaceSelectorItem key={workspace.id} workspace={workspace} />
))}

View File

@ -17,7 +17,7 @@ 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 { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectLabel, SelectTrigger } from '@langgenius/dify-ui/select'
import { useCallback, useState } from 'react'
import { Infotip } from '@/app/components/base/infotip'
import { AppSelector } from '@/app/components/plugins/plugin-detail-panel/app-selector'
@ -293,11 +293,12 @@ function Form<
? formSchema.default
: value[variable]
const selectedOption = filteredOptions.find(option => option.value === currentValue)
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}
{translatedLabel}
{required && (
<span className="ml-1 text-red-500">*</span>
@ -313,6 +314,7 @@ function Form<
handleFormChange(variable, nextValue)
}}
>
<SelectLabel className="sr-only">{translatedLabel}</SelectLabel>
<SelectTrigger size="medium" className={cn(inputClassName)}>
{selectedOption?.name ?? placeholder?.[language] ?? placeholder?.en_US}
</SelectTrigger>

View File

@ -1234,7 +1234,7 @@ describe('Form', () => {
expect(screen.getByText('API Key'))!.toBeInTheDocument()
expect(screen.getByText('Region'))!.toBeInTheDocument()
expect(screen.getByText('Model'))!.toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'Model' }))!.toBeInTheDocument()
expect(screen.getByText('Agree'))!.toBeInTheDocument()
expect(screen.getByLabelText('Enter your API key here'))!.toBeInTheDocument()
expect(screen.getByLabelText('Select region'))!.toBeInTheDocument()
@ -1546,7 +1546,7 @@ describe('Form', () => {
expect(screen.getByText('API Key Fallback'))!.toBeInTheDocument()
expect(screen.getByText('Region Fallback'))!.toBeInTheDocument()
expect(screen.getByText('Model Fallback'))!.toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'Model Fallback' }))!.toBeInTheDocument()
expect(screen.getByText('Agree Fallback'))!.toBeInTheDocument()
})

View File

@ -20,6 +20,7 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
),
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectLabel: () => null,
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => <div>SelectValue</div>,
SelectItemText: ({ children }: { children: ReactNode }) => <span>{children}</span>,

View File

@ -8,7 +8,7 @@ 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 { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectLabel, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select'
import { Slider } from '@langgenius/dify-ui/slider'
import { Switch } from '@langgenius/dify-ui/switch'
import { useEffect, useMemo, useRef, useState } from 'react'
@ -165,58 +165,90 @@ function ParameterItem({
step = 10
}
return (
<>
{numberInputWithSlide && (
<Slider
className="w-[120px]"
value={renderValue as number}
min={parameterRule.min}
max={parameterRule.max}
step={step}
onValueChange={handleSlideChange}
aria-label={sliderLabel}
/>
)}
if (!numberInputWithSlide) {
return (
<input
aria-label={sliderLabel}
ref={numberInputRef}
className="ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 system-sm-regular text-components-input-text-filled outline-hidden"
type="number"
max={parameterRule.max}
min={parameterRule.min}
step={numberInputWithSlide ? step : +`0.${parameterRule.precision || 0}`}
step={+`0.${parameterRule.precision || 0}`}
onChange={handleNumberInputChange}
onBlur={handleNumberInputBlur}
/>
</>
)
}
return (
<FieldsetRoot className="flex items-center">
<FieldsetLegend className="sr-only">{sliderLabel}</FieldsetLegend>
<Slider
className="w-[120px]"
value={renderValue as number}
min={parameterRule.min}
max={parameterRule.max}
step={step}
onValueChange={handleSlideChange}
aria-label={sliderLabel}
/>
<input
aria-label={sliderLabel}
ref={numberInputRef}
className="ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 system-sm-regular text-components-input-text-filled outline-hidden"
type="number"
max={parameterRule.max}
min={parameterRule.min}
step={step}
onChange={handleNumberInputChange}
onBlur={handleNumberInputBlur}
/>
</FieldsetRoot>
)
}
if (parameterRule.type === 'float') {
return (
<>
{numberInputWithSlide && (
<Slider
className="w-[120px]"
value={renderValue as number}
min={parameterRule.min}
max={parameterRule.max}
step={0.1}
onValueChange={handleSlideChange}
aria-label={sliderLabel}
/>
)}
if (!numberInputWithSlide) {
return (
<input
aria-label={sliderLabel}
ref={numberInputRef}
className="ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 system-sm-regular text-components-input-text-filled outline-hidden"
type="number"
max={parameterRule.max}
min={parameterRule.min}
step={numberInputWithSlide ? 0.1 : +`0.${parameterRule.precision || 0}`}
step={+`0.${parameterRule.precision || 0}`}
onChange={handleNumberInputChange}
onBlur={handleNumberInputBlur}
/>
</>
)
}
return (
<FieldsetRoot className="flex items-center">
<FieldsetLegend className="sr-only">{sliderLabel}</FieldsetLegend>
<Slider
className="w-[120px]"
value={renderValue as number}
min={parameterRule.min}
max={parameterRule.max}
step={0.1}
onValueChange={handleSlideChange}
aria-label={sliderLabel}
/>
<input
aria-label={sliderLabel}
ref={numberInputRef}
className="ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 system-sm-regular text-components-input-text-filled outline-hidden"
type="number"
max={parameterRule.max}
min={parameterRule.min}
step={0.1}
onChange={handleNumberInputChange}
onBlur={handleNumberInputBlur}
/>
</FieldsetRoot>
)
}
@ -317,6 +349,7 @@ function ParameterItem({
value={renderValue as string}
onValueChange={v => handleInputChange(v ?? undefined)}
>
<SelectLabel className="sr-only">{sliderLabel}</SelectLabel>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>

View File

@ -38,6 +38,7 @@ vi.mock('@langgenius/dify-ui/select', async () => {
<div>{children}</div>
</SelectContext.Provider>
),
SelectLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(SelectContext)
return (
@ -149,7 +150,8 @@ describe('SelectPackage', () => {
const getSection = (label: string): HTMLElement => {
const labelElement = screen.getByText(label)
const section = labelElement.closest('label')?.nextElementSibling
const labelContainer = labelElement.closest('label') ?? labelElement.parentElement
const section = labelContainer?.parentElement
if (!(section instanceof HTMLElement))
throw new Error(`Missing section for ${label}`)
return section

View File

@ -2,8 +2,8 @@
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
import { Button } from '@langgenius/dify-ui/button'
import { FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { FieldRoot } from '@langgenius/dify-ui/field'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectLabel, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
@ -79,9 +79,6 @@ const SelectPackage: React.FC<SelectPackageProps> = ({
return (
<>
<FieldRoot name="version" className="gap-4 self-stretch">
<FieldLabel className="flex w-full flex-col items-start justify-center p-0 text-text-secondary">
<span className="system-sm-semibold">{t(`${i18nPrefix}.selectVersion`, { ns: 'plugin' })}</span>
</FieldLabel>
<Select
value={selectedVersionOption ? String(selectedVersionOption.value) : null}
onValueChange={(value) => {
@ -92,6 +89,9 @@ const SelectPackage: React.FC<SelectPackageProps> = ({
onSelectVersion(selectedItem)
}}
>
<SelectLabel className="flex w-full flex-col items-start justify-center p-0 text-text-secondary">
<span className="system-sm-semibold">{t(`${i18nPrefix}.selectVersion`, { ns: 'plugin' })}</span>
</SelectLabel>
<SelectTrigger className="h-9 text-components-input-text-filled">
<div className="flex items-center justify-between gap-2">
<span className="truncate">
@ -122,9 +122,6 @@ const SelectPackage: React.FC<SelectPackageProps> = ({
</Select>
</FieldRoot>
<FieldRoot name="package" className="gap-4 self-stretch">
<FieldLabel className="flex w-full flex-col items-start justify-center p-0 text-text-secondary">
<span className="system-sm-semibold">{t(`${i18nPrefix}.selectPackage`, { ns: 'plugin' })}</span>
</FieldLabel>
<Select
value={selectedPackageOption ? String(selectedPackageOption.value) : null}
readOnly={!selectedVersion}
@ -136,6 +133,9 @@ const SelectPackage: React.FC<SelectPackageProps> = ({
onSelectPackage(selectedItem)
}}
>
<SelectLabel className="flex w-full flex-col items-start justify-center p-0 text-text-secondary">
<span className="system-sm-semibold">{t(`${i18nPrefix}.selectPackage`, { ns: 'plugin' })}</span>
</SelectLabel>
<SelectTrigger className="h-9 text-components-input-text-filled">
{selectedPackageOption?.name ?? t(`${i18nPrefix}.selectPackagePlaceholder`, { ns: 'plugin' }) ?? ''}
</SelectTrigger>

View File

@ -145,8 +145,7 @@ describe('AgentStrategy', () => {
/>,
)
expect(screen.getByLabelText('Count')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'Count' })).toBeInTheDocument()
})
it('should skip text-number schemas when min is missing', () => {

View File

@ -4,6 +4,7 @@ import type { NodeOutPutVar } from '../../../types'
import type { ToolVarInputs } from '../../tool/types'
import type { CredentialFormSchema, CredentialFormSchemaNumberInput, CredentialFormSchemaTextInput } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { PluginMeta } from '@/app/components/plugins/types'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import {
NumberField,
NumberFieldControls,
@ -128,6 +129,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
const defaultValue = schema.default ? Number.parseInt(schema.default) : 1
const value = props.value[schema.variable] ?? defaultValue
const label = renderI18nObject(def.label)
const onChange = (value: number) => {
props.onChange({ ...props.value, [schema.variable]: value })
}
@ -135,7 +137,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
<Field
title={(
<>
{renderI18nObject(def.label)}
{label}
{' '}
{def.required && <span className="text-red-500">*</span>}
</>
@ -144,14 +146,15 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
tooltip={def.tooltip && renderI18nObject(def.tooltip)}
inline
>
<div className="flex w-[200px] items-center gap-3">
<FieldsetRoot className="flex w-[200px] items-center gap-3">
<FieldsetLegend className="sr-only">{label}</FieldsetLegend>
<Slider
value={value}
onValueChange={onChange}
className="w-full"
min={def.min}
max={def.max}
aria-label={renderI18nObject(def.label)}
aria-label={label}
/>
<NumberField
value={value}
@ -160,14 +163,14 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
onValueChange={nextValue => onChange(nextValue ?? defaultValue)}
>
<NumberFieldGroup>
<NumberFieldInput className="w-12" />
<NumberFieldInput aria-label={label} className="w-12" />
<NumberFieldControls>
<NumberFieldIncrement />
<NumberFieldDecrement />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</div>
</FieldsetRoot>
</Field>
)
}

View File

@ -161,6 +161,7 @@ const FileUploadSetting: FC<Props> = ({
</div>
<InputNumberWithSlider
label={t('variableConfig.maxNumberOfUploads', { ns: 'appDebug' })!}
value={max_length}
min={1}
max={maxFileUploadLimit}

View File

@ -1,10 +1,11 @@
'use client'
import type { FC } from 'react'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { Slider } from '@langgenius/dify-ui/slider'
import * as React from 'react'
import { useCallback } from 'react'
export type InputNumberWithSliderProps = {
label: string
value: number
defaultValue?: number
min?: number
@ -13,14 +14,15 @@ export type InputNumberWithSliderProps = {
onChange: (value: number) => void
}
const InputNumberWithSlider: FC<InputNumberWithSliderProps> = ({
function InputNumberWithSlider({
label,
value,
defaultValue = 0,
min,
max,
readonly,
onChange,
}) => {
}: InputNumberWithSliderProps) {
const handleBlur = useCallback(() => {
if (value === undefined || value === null || Number.isNaN(value)) {
onChange(defaultValue)
@ -39,29 +41,33 @@ const InputNumberWithSlider: FC<InputNumberWithSliderProps> = ({
}, [onChange])
return (
<div className="flex h-8 items-center justify-between space-x-2">
<input
value={value}
className="block h-8 w-12 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-[13px] text-components-input-text-filled outline-hidden"
type="number"
min={min}
max={max}
step={1}
onChange={handleChange}
onBlur={handleBlur}
disabled={readonly}
/>
<Slider
className="grow"
value={value}
min={min}
max={max}
step={1}
onValueChange={onChange}
disabled={readonly}
aria-label="Number input slider"
/>
</div>
<FieldsetRoot>
<FieldsetLegend className="sr-only">{label}</FieldsetLegend>
<div className="flex h-8 items-center justify-between space-x-2">
<input
aria-label={label}
value={value}
className="block h-8 w-12 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-[13px] text-components-input-text-filled outline-hidden"
type="number"
min={min}
max={max}
step={1}
onChange={handleChange}
onBlur={handleBlur}
disabled={readonly}
/>
<Slider
className="grow"
value={value}
min={min}
max={max}
step={1}
onValueChange={onChange}
disabled={readonly}
aria-label={label}
/>
</div>
</FieldsetRoot>
)
}
export default React.memo(InputNumberWithSlider)

View File

@ -2,6 +2,7 @@
import type { FC } from 'react'
import type { Memory } from '../../../types'
import { cn } from '@langgenius/dify-ui/cn'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { Slider } from '@langgenius/dify-ui/slider'
import { Switch } from '@langgenius/dify-ui/switch'
import { produce } from 'immer'
@ -67,6 +68,7 @@ const MemoryConfig: FC<Props> = ({
}) => {
const { t } = useTranslation()
const payload = config.data
const windowSizeLabel = t(`${i18nPrefix}.windowSize`, { ns: 'workflow' })
const handleMemoryEnabledChange = useCallback((enabled: boolean) => {
onChange(enabled ? MEMORY_DEFAULT : undefined)
}, [onChange])
@ -154,9 +156,10 @@ const MemoryConfig: FC<Props> = ({
size="md"
disabled={readonly}
/>
<div className="system-xs-medium-uppercase text-text-tertiary">{t(`${i18nPrefix}.windowSize`, { ns: 'workflow' })}</div>
<div className="system-xs-medium-uppercase text-text-tertiary">{windowSizeLabel}</div>
</div>
<div className="flex h-8 items-center space-x-2">
<FieldsetRoot className="flex h-8 items-center space-x-2">
<FieldsetLegend className="sr-only">{windowSizeLabel}</FieldsetLegend>
<Slider
className="w-[144px]"
value={(payload.window?.size || WINDOW_SIZE_DEFAULT) as number}
@ -165,9 +168,10 @@ const MemoryConfig: FC<Props> = ({
step={1}
onValueChange={handleWindowSizeChange}
disabled={readonly || !payload.window?.enabled}
aria-label={t(`${i18nPrefix}.windowSize`, { ns: 'workflow' })}
aria-label={windowSizeLabel}
/>
<Input
aria-label={windowSizeLabel}
value={(payload.window?.size || WINDOW_SIZE_DEFAULT) as number}
wrapperClassName="w-12"
className="appearance-none pr-0"
@ -179,7 +183,7 @@ const MemoryConfig: FC<Props> = ({
onBlur={handleBlur}
disabled={readonly || !payload.window?.enabled}
/>
</div>
</FieldsetRoot>
</div>
{canSetRoleName && (
<div className="mt-4">

View File

@ -1,6 +1,7 @@
import type {
Node,
} from '@/app/components/workflow/types'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { Slider } from '@langgenius/dify-ui/slider'
import { Switch } from '@langgenius/dify-ui/switch'
import { useTranslation } from 'react-i18next'
@ -17,6 +18,8 @@ const RetryOnPanel = ({
const { t } = useTranslation()
const { handleRetryConfigChange } = useRetryConfig(id)
const { retry_config } = data
const maxRetriesLabel = t('nodes.common.retry.maxRetries', { ns: 'workflow' })
const retryIntervalLabel = t('nodes.common.retry.retryInterval', { ns: 'workflow' })
const handleRetryEnabledChange = (value: boolean) => {
handleRetryConfigChange({
@ -65,17 +68,19 @@ const RetryOnPanel = ({
{
retry_config?.retry_enabled && (
<div className="px-4 pb-2">
<div className="mb-1 flex w-full items-center">
<div className="mr-2 grow system-xs-medium-uppercase text-text-secondary">{t('nodes.common.retry.maxRetries', { ns: 'workflow' })}</div>
<FieldsetRoot className="mb-1 flex w-full items-center">
<FieldsetLegend className="sr-only">{maxRetriesLabel}</FieldsetLegend>
<div className="mr-2 grow system-xs-medium-uppercase text-text-secondary">{maxRetriesLabel}</div>
<Slider
className="mr-3 w-[108px]"
value={retry_config?.max_retries || 3}
onValueChange={handleMaxRetriesChange}
min={1}
max={10}
aria-label={t('nodes.common.retry.maxRetries', { ns: 'workflow' })}
aria-label={maxRetriesLabel}
/>
<Input
aria-label={maxRetriesLabel}
type="number"
wrapperClassName="w-[100px]"
value={retry_config?.max_retries || 3}
@ -86,18 +91,20 @@ const RetryOnPanel = ({
unit={t('nodes.common.retry.times', { ns: 'workflow' }) || ''}
className={s.input}
/>
</div>
<div className="flex items-center">
<div className="mr-2 grow system-xs-medium-uppercase text-text-secondary">{t('nodes.common.retry.retryInterval', { ns: 'workflow' })}</div>
</FieldsetRoot>
<FieldsetRoot className="flex items-center">
<FieldsetLegend className="sr-only">{retryIntervalLabel}</FieldsetLegend>
<div className="mr-2 grow system-xs-medium-uppercase text-text-secondary">{retryIntervalLabel}</div>
<Slider
className="mr-3 w-[108px]"
value={retry_config?.retry_interval || 1000}
onValueChange={handleRetryIntervalChange}
min={100}
max={5000}
aria-label={t('nodes.common.retry.retryInterval', { ns: 'workflow' })}
aria-label={retryIntervalLabel}
/>
<Input
aria-label={retryIntervalLabel}
type="number"
wrapperClassName="w-[100px]"
value={retry_config?.retry_interval || 1000}
@ -108,7 +115,7 @@ const RetryOnPanel = ({
unit={t('nodes.common.retry.ms', { ns: 'workflow' }) || ''}
className={s.input}
/>
</div>
</FieldsetRoot>
</div>
)
}

View File

@ -1,7 +1,8 @@
import type { FC } from 'react'
import type { IterationNodeType } from './types'
import type { NodePanelProps } from '@/app/components/workflow/types'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectLabel, SelectTrigger } from '@langgenius/dify-ui/select'
import { Slider } from '@langgenius/dify-ui/slider'
import { Switch } from '@langgenius/dify-ui/switch'
import * as React from 'react'
@ -22,6 +23,8 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
data,
}) => {
const { t } = useTranslation()
const maxParallelismLabel = t(`${i18nPrefix}.MaxParallelismTitle`, { ns: 'workflow' })
const errorResponseMethodLabel = t(`${i18nPrefix}.errorResponseMethod`, { ns: 'workflow' })
const responseMethod = [
{
value: ErrorHandleMode.Terminated,
@ -99,18 +102,19 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
{
inputs.is_parallel && (
<div className="px-4 pb-2">
<Field title={t(`${i18nPrefix}.MaxParallelismTitle`, { ns: 'workflow' })} isSubTitle tooltip={<div className="w-[230px]">{t(`${i18nPrefix}.MaxParallelismDesc`, { ns: 'workflow' })}</div>}>
<div className="row flex">
<Input type="number" wrapperClassName="w-18 mr-4" max={MAX_PARALLEL_LIMIT} min={MIN_ITERATION_PARALLEL_NUM} value={inputs.parallel_nums} onChange={(e) => { changeParallelNums(Number(e.target.value)) }} />
<Field title={maxParallelismLabel} isSubTitle tooltip={<div className="w-[230px]">{t(`${i18nPrefix}.MaxParallelismDesc`, { ns: 'workflow' })}</div>}>
<FieldsetRoot className="row flex">
<FieldsetLegend className="sr-only">{maxParallelismLabel}</FieldsetLegend>
<Input aria-label={maxParallelismLabel} type="number" wrapperClassName="w-18 mr-4" max={MAX_PARALLEL_LIMIT} min={MIN_ITERATION_PARALLEL_NUM} value={inputs.parallel_nums} onChange={(e) => { changeParallelNums(Number(e.target.value)) }} />
<Slider
value={inputs.parallel_nums}
onValueChange={changeParallelNums}
max={MAX_PARALLEL_LIMIT}
min={MIN_ITERATION_PARALLEL_NUM}
className="mt-4 flex-1 shrink-0"
aria-label={t(`${i18nPrefix}.MaxParallelismTitle`, { ns: 'workflow' })}
aria-label={maxParallelismLabel}
/>
</div>
</FieldsetRoot>
</Field>
</div>
@ -119,7 +123,7 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
<Split />
<div className="px-4 py-2">
<Field title={t(`${i18nPrefix}.errorResponseMethod`, { ns: 'workflow' })}>
<Field title={errorResponseMethodLabel}>
<Select
value={selectedResponseMethod ? String(selectedResponseMethod.value) : null}
onValueChange={(nextValue) => {
@ -130,6 +134,7 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
changeErrorResponseMode(nextItem)
}}
>
<SelectLabel className="sr-only">{errorResponseMethodLabel}</SelectLabel>
<SelectTrigger className="w-full">
{selectedResponseMethod?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>

View File

@ -1,4 +1,5 @@
import { cn } from '@langgenius/dify-ui/cn'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { Slider } from '@langgenius/dify-ui/slider'
import {
memo,
@ -35,6 +36,7 @@ const IndexMethod = ({
readonly = false,
}: IndexMethodProps) => {
const { t } = useTranslation()
const keywordNumberLabel = t('form.numberOfKeywords', { ns: 'datasetSettings' })
const isHighQuality = indexMethod === IndexMethodEnum.QUALIFIED
const isEconomy = indexMethod === IndexMethodEnum.ECONOMICAL
@ -91,16 +93,17 @@ const IndexMethod = ({
onClick={handleIndexMethodChange}
effectColor="blue"
>
<div className="flex items-center">
<FieldsetRoot className="flex items-center">
<FieldsetLegend className="sr-only">{keywordNumberLabel}</FieldsetLegend>
<div className="flex grow items-center">
<div className="truncate system-xs-medium text-text-secondary">
{t('form.numberOfKeywords', { ns: 'datasetSettings' })}
{keywordNumberLabel}
</div>
<Infotip
aria-label={t('form.numberOfKeywords', { ns: 'datasetSettings' })}
aria-label={keywordNumberLabel}
className="ml-0.5 size-3.5"
>
{t('form.numberOfKeywords', { ns: 'datasetSettings' })}
{keywordNumberLabel}
</Infotip>
</div>
<Slider
@ -108,9 +111,10 @@ const IndexMethod = ({
className="mr-3 w-24 shrink-0"
value={keywordNumber}
onValueChange={onKeywordNumberChange}
aria-label={t('form.numberOfKeywords', { ns: 'datasetSettings' })}
aria-label={keywordNumberLabel}
/>
<Input
aria-label={keywordNumberLabel}
disabled={readonly}
className="shrink-0"
wrapperClassName="shrink-0 w-[72px]"
@ -118,7 +122,7 @@ const IndexMethod = ({
value={keywordNumber}
onChange={handleInputChange}
/>
</div>
</FieldsetRoot>
</OptionCard>
)
}

View File

@ -46,6 +46,8 @@ const TopKAndScoreThreshold = ({
hiddenScoreThreshold,
}: TopKAndScoreThresholdProps) => {
const { t } = useTranslation()
const topKLabel = t('datasetConfig.top_k', { ns: 'appDebug' })
const scoreThresholdLabel = t('datasetConfig.score_threshold', { ns: 'appDebug' })
const handleTopKChange = useCallback((value: number) => {
onTopKChange?.(Number.parseInt(value.toFixed(0)))
}, [onTopKChange])
@ -58,7 +60,7 @@ const TopKAndScoreThreshold = ({
<div className="grid grid-cols-2 gap-4">
<div>
<div className="mb-0.5 flex h-6 items-center system-xs-medium text-text-secondary">
{t('datasetConfig.top_k', { ns: 'appDebug' })}
{topKLabel}
<Infotip
aria-label={t('datasetConfig.top_kTip', { ns: 'appDebug' })}
className="ml-0.5 size-3.5"
@ -76,7 +78,7 @@ const TopKAndScoreThreshold = ({
onValueChange={value => handleTopKChange(value ?? 0)}
>
<NumberFieldGroup>
<NumberFieldInput />
<NumberFieldInput aria-label={topKLabel} />
<NumberFieldControls>
<NumberFieldIncrement />
<NumberFieldDecrement />
@ -95,7 +97,7 @@ const TopKAndScoreThreshold = ({
disabled={readonly}
/>
<div className="grow truncate system-sm-medium text-text-secondary">
{t('datasetConfig.score_threshold', { ns: 'appDebug' })}
{scoreThresholdLabel}
</div>
<Infotip
aria-label={t('datasetConfig.score_thresholdTip', { ns: 'appDebug' })}
@ -114,7 +116,7 @@ const TopKAndScoreThreshold = ({
onValueChange={value => handleScoreThresholdChange(value ?? 0)}
>
<NumberFieldGroup>
<NumberFieldInput />
<NumberFieldInput aria-label={scoreThresholdLabel} />
<NumberFieldControls>
<NumberFieldIncrement />
<NumberFieldDecrement />

View File

@ -9,6 +9,7 @@ type MockSwitchProps = {
}
type MockSliderProps = {
label: string
value: number
min: number
max: number
@ -108,6 +109,7 @@ describe('list-operator/limit-config', () => {
min: 1,
max: 20,
readonly: true,
label: 'workflow.nodes.listFilter.limit',
})
fireEvent.click(screen.getByRole('button', { name: 'slider:6:true' }))

View File

@ -66,6 +66,7 @@ const LimitConfig: FC<Props> = ({
{payload?.enabled
? (
<InputNumberWithSlider
label={t(`${i18nPrefix}.limit`, { ns: 'workflow' })}
value={payload?.size || LIMIT_SIZE_DEFAULT}
min={LIMIT_SIZE_MIN}
max={LIMIT_SIZE_MAX}

View File

@ -92,6 +92,7 @@ const Panel: FC<NodePanelProps<LoopNodeType>> = ({
>
<div className="px-3 py-2">
<InputNumberWithSlider
label={t(`${i18nPrefix}.loopMaxCount`, { ns: 'workflow' })}
min={1}
max={LOOP_NODE_MAX_COUNT}
value={inputs.loop_count}

View File

@ -3,6 +3,7 @@ import {
Select,
SelectContent,
SelectGroup,
SelectGroupLabel,
SelectItem,
SelectItemIndicator,
SelectItemText,
@ -24,6 +25,7 @@ type FrequencySelectorProps = {
const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => {
const { t } = useTranslation()
const groupLabel = t('nodes.triggerSchedule.frequency.label', { ns: 'workflow' })
const fieldLabel = t('nodes.triggerSchedule.frequencyLabel', { ns: 'workflow' })
const frequencies: FrequencyOption[] = [
{ value: 'hourly', name: t('nodes.triggerSchedule.frequency.hourly', { ns: 'workflow' }) },
@ -45,12 +47,13 @@ const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => {
value={frequency}
onValueChange={handleFrequencyChange}
>
<SelectLabel className="sr-only">{fieldLabel}</SelectLabel>
<SelectTrigger className="w-full py-2">
{selectedFrequency?.name ?? t('nodes.triggerSchedule.selectFrequency', { ns: 'workflow' })}
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{groupLabel}</SelectLabel>
<SelectGroupLabel>{groupLabel}</SelectGroupLabel>
{frequencies.map(item => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>

View File

@ -48,6 +48,7 @@ vi.mock('@langgenius/dify-ui/select', async () => {
<div>{children}</div>
</SelectContext.Provider>
),
SelectLabel: () => null,
SelectTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => {
const context = React.useContext(SelectContext)
return (

View File

@ -9,7 +9,7 @@ import {
NumberFieldIncrement,
NumberFieldInput,
} from '@langgenius/dify-ui/number-field'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectLabel, SelectTrigger } from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import copy from 'copy-to-clipboard'
@ -203,6 +203,7 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
disabled={readOnly}
onValueChange={value => value && handleContentTypeChange(value)}
>
<SelectLabel className="sr-only">{t(`${i18nPrefix}.contentType`, { ns: 'workflow' })}</SelectLabel>
<SelectTrigger className="h-8 w-full text-sm">
{selectedContentType?.name}
</SelectTrigger>
@ -267,6 +268,7 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
>
<NumberFieldGroup>
<NumberFieldInput
aria-label={t(`${i18nPrefix}.statusCode`, { ns: 'workflow' })}
className="h-8"
/>
<NumberFieldControls>